@automattic/charts 4.12.0 → 4.14.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 +35 -29
- package/TOKENS.md +3 -0
- package/dist/index.cjs +733 -2203
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +42 -13
- package/dist/index.d.cts +41 -125
- package/dist/index.d.ts +41 -125
- package/dist/index.js +756 -1881
- 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 +17 -12
- package/src/charts/bar-chart/private/index.ts +1 -1
- package/src/charts/bar-chart/private/slot-tooltip-members.tsx +4 -5
- package/src/charts/bar-chart/private/use-bar-stops.ts +1 -3
- package/src/charts/bar-chart/private/use-overlap-slots.ts +35 -16
- package/src/charts/bar-chart/test/bar-chart.test.tsx +115 -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 +42 -2
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +124 -36
- package/src/charts/leaderboard-chart/private/resolve-leaderboard-entries.ts +53 -0
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +88 -0
- package/src/charts/leaderboard-chart/test/resolve-leaderboard-entries.test.ts +146 -0
- package/src/charts/leaderboard-chart/types.ts +6 -0
- package/src/charts/line-chart/line-chart.tsx +10 -8
- package/src/charts/line-chart/test/line-chart.test.tsx +75 -0
- package/src/components/legend/private/base-legend.tsx +13 -8
- package/src/components/legend/test/legend.test.tsx +125 -0
- package/src/components/legend/types.ts +9 -1
- 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 +8 -12
- package/src/index.ts +2 -0
- package/src/styles/chart-scope.scss +1 -0
- package/src/types.ts +5 -6
|
@@ -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
|
}
|
|
@@ -49,7 +49,13 @@
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
.label {
|
|
52
|
-
padding-
|
|
52
|
+
padding-inline-start: var(--a8c-charts-dimension-leaderboard-label-inset);
|
|
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;
|
|
53
59
|
}
|
|
54
60
|
}
|
|
55
61
|
|
|
@@ -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 {
|
|
@@ -23,6 +23,10 @@ import { getAllHiddenMessage } from '../private/svg-empty-state';
|
|
|
23
23
|
import { withResponsive } from '../private/with-responsive';
|
|
24
24
|
import { useFittedRowCount, useLeaderboardLegendItems } from './hooks';
|
|
25
25
|
import styles from './leaderboard-chart.module.scss';
|
|
26
|
+
import {
|
|
27
|
+
resolveLeaderboardEntries,
|
|
28
|
+
type ResolvedLeaderboardEntry,
|
|
29
|
+
} from './private/resolve-leaderboard-entries';
|
|
26
30
|
import type { LeaderboardChartProps } from './types';
|
|
27
31
|
import type { LeaderboardEntry } from '../../types';
|
|
28
32
|
|
|
@@ -75,15 +79,15 @@ const getBarWidth = ( share: number ): string =>
|
|
|
75
79
|
`calc(${ share }% - var(--a8c-charts-dimension-leaderboard-bar-hover-inset, 0px) * ${ share } / 100)`;
|
|
76
80
|
|
|
77
81
|
const hasPreviousValue = (
|
|
78
|
-
entry:
|
|
79
|
-
): entry is
|
|
82
|
+
entry: ResolvedLeaderboardEntry
|
|
83
|
+
): entry is ResolvedLeaderboardEntry & {
|
|
80
84
|
previousValue: number;
|
|
81
85
|
previousShare: number;
|
|
82
86
|
} => entry.previousValue != null && entry.previousShare != null;
|
|
83
87
|
|
|
84
88
|
const hasDeltaValue = (
|
|
85
|
-
entry:
|
|
86
|
-
): entry is
|
|
89
|
+
entry: ResolvedLeaderboardEntry
|
|
90
|
+
): entry is ResolvedLeaderboardEntry & {
|
|
87
91
|
previousValue: number;
|
|
88
92
|
previousShare: number;
|
|
89
93
|
delta: number;
|
|
@@ -107,6 +111,56 @@ const BarLabel = ( { label }: { label: LeaderboardEntry[ 'label' ] } ) => (
|
|
|
107
111
|
<>{ typeof label === 'string' ? <Text className={ styles.label }>{ label }</Text> : label }</>
|
|
108
112
|
);
|
|
109
113
|
|
|
114
|
+
/** The largest negative and positive share magnitudes, which size each side of zero. */
|
|
115
|
+
type DivergingScale = { negative: number; positive: number };
|
|
116
|
+
|
|
117
|
+
const Bar = ( {
|
|
118
|
+
share,
|
|
119
|
+
color,
|
|
120
|
+
animation,
|
|
121
|
+
divergingScale,
|
|
122
|
+
}: {
|
|
123
|
+
share: number;
|
|
124
|
+
color: string;
|
|
125
|
+
animation?: boolean;
|
|
126
|
+
divergingScale?: DivergingScale;
|
|
127
|
+
} ) => {
|
|
128
|
+
const isNegative = share < 0;
|
|
129
|
+
const sideMax = divergingScale?.[ isNegative ? 'negative' : 'positive' ];
|
|
130
|
+
// A diverging bar is measured against the largest share on its own side of zero.
|
|
131
|
+
const width = sideMax ? ( Math.abs( share ) / sideMax ) * 100 : Math.abs( share );
|
|
132
|
+
|
|
133
|
+
const bar = (
|
|
134
|
+
<div
|
|
135
|
+
data-testid="leaderboard-chart-bar"
|
|
136
|
+
className={ clsx( styles.bar, {
|
|
137
|
+
[ styles[ 'bar--animated' ] ]: animation,
|
|
138
|
+
[ styles[ 'bar--negative' ] ]: divergingScale && isNegative,
|
|
139
|
+
} ) }
|
|
140
|
+
style={ {
|
|
141
|
+
width: getBarWidth( width ),
|
|
142
|
+
backgroundColor: color,
|
|
143
|
+
} }
|
|
144
|
+
></div>
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
if ( ! divergingScale ) {
|
|
148
|
+
return bar;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Each side is as wide as its share of the full range, so zero sits where the range crosses it.
|
|
152
|
+
return (
|
|
153
|
+
<div
|
|
154
|
+
className={ styles.barTrack }
|
|
155
|
+
style={ {
|
|
156
|
+
gridTemplateColumns: `${ divergingScale.negative }fr ${ divergingScale.positive }fr`,
|
|
157
|
+
} }
|
|
158
|
+
>
|
|
159
|
+
{ bar }
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
};
|
|
163
|
+
|
|
110
164
|
const BarWithLabel = ( {
|
|
111
165
|
entry,
|
|
112
166
|
withComparison,
|
|
@@ -114,51 +168,53 @@ const BarWithLabel = ( {
|
|
|
114
168
|
primaryColor,
|
|
115
169
|
secondaryColor,
|
|
116
170
|
animation,
|
|
171
|
+
divergingScale,
|
|
117
172
|
isPrimaryVisible = true,
|
|
118
173
|
isComparisonVisible = true,
|
|
119
174
|
}: {
|
|
120
|
-
entry:
|
|
175
|
+
entry: ResolvedLeaderboardEntry;
|
|
121
176
|
withComparison?: boolean;
|
|
122
177
|
withOverlayLabel?: boolean;
|
|
123
178
|
primaryColor: string;
|
|
124
179
|
secondaryColor: string;
|
|
180
|
+
divergingScale?: DivergingScale;
|
|
125
181
|
isPrimaryVisible?: boolean;
|
|
126
182
|
isComparisonVisible?: boolean;
|
|
127
183
|
animation?: boolean;
|
|
128
184
|
} ) => {
|
|
129
185
|
const showComparisonBar = withComparison && ! withOverlayLabel && isComparisonVisible;
|
|
130
186
|
|
|
187
|
+
const bars = (
|
|
188
|
+
<>
|
|
189
|
+
{ isPrimaryVisible && (
|
|
190
|
+
<Bar
|
|
191
|
+
share={ entry.currentShare }
|
|
192
|
+
color={ primaryColor }
|
|
193
|
+
animation={ animation }
|
|
194
|
+
divergingScale={ divergingScale }
|
|
195
|
+
/>
|
|
196
|
+
) }
|
|
197
|
+
|
|
198
|
+
{ showComparisonBar && hasPreviousValue( entry ) && (
|
|
199
|
+
<Bar
|
|
200
|
+
share={ entry.previousShare }
|
|
201
|
+
color={ secondaryColor }
|
|
202
|
+
animation={ animation }
|
|
203
|
+
divergingScale={ divergingScale }
|
|
204
|
+
/>
|
|
205
|
+
) }
|
|
206
|
+
</>
|
|
207
|
+
);
|
|
208
|
+
|
|
131
209
|
return (
|
|
132
210
|
<div
|
|
133
211
|
className={ clsx( styles.barWithLabelContainer, {
|
|
134
212
|
[ styles[ 'is-overlay' ] ]: withOverlayLabel,
|
|
135
213
|
} ) }
|
|
214
|
+
data-testid={ divergingScale ? 'leaderboard-chart-diverging-row' : undefined }
|
|
136
215
|
>
|
|
137
216
|
<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
|
-
) }
|
|
217
|
+
{ bars }
|
|
162
218
|
</div>
|
|
163
219
|
);
|
|
164
220
|
};
|
|
@@ -174,6 +230,7 @@ const BarWithLabel = ( {
|
|
|
174
230
|
* @param props.height - Optional height of the chart container in pixels
|
|
175
231
|
* @param props.withComparison - Whether to show comparison data
|
|
176
232
|
* @param props.withOverlayLabel - Whether to overlay the label on top of the bar
|
|
233
|
+
* @param props.negativeBars - How a negative value draws: from a shared zero point, or as an empty bar
|
|
177
234
|
* @param props.primaryColor - Primary color for current period bars
|
|
178
235
|
* @param props.secondaryColor - Secondary color for comparison period bars
|
|
179
236
|
* @param props.valueFormatter - Custom formatter for values
|
|
@@ -198,6 +255,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
198
255
|
height: propHeight,
|
|
199
256
|
withComparison = false,
|
|
200
257
|
withOverlayLabel = false,
|
|
258
|
+
negativeBars = 'diverge',
|
|
201
259
|
primaryColor,
|
|
202
260
|
secondaryColor,
|
|
203
261
|
valueFormatter = defaultValueFormatter,
|
|
@@ -267,6 +325,33 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
267
325
|
return ! isPrimaryVisible;
|
|
268
326
|
}, [ isPrimaryVisible, isComparisonVisible, withComparison, withOverlayLabel ] );
|
|
269
327
|
|
|
328
|
+
const withComparisonBars = withComparison && ! withOverlayLabel;
|
|
329
|
+
|
|
330
|
+
const resolvedData = useMemo(
|
|
331
|
+
() =>
|
|
332
|
+
resolveLeaderboardEntries( data ?? [], {
|
|
333
|
+
includePrevious: withComparisonBars,
|
|
334
|
+
clampNegative: negativeBars === 'clamp',
|
|
335
|
+
} ),
|
|
336
|
+
[ data, withComparisonBars, negativeBars ]
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
// One negative share puts every row on a shared zero point, so the bars stay comparable.
|
|
340
|
+
const divergingScale = useMemo( (): DivergingScale | undefined => {
|
|
341
|
+
const scale = { negative: 0, positive: 0 };
|
|
342
|
+
for ( const entry of resolvedData ) {
|
|
343
|
+
const shares = [ entry.currentShare ];
|
|
344
|
+
if ( withComparisonBars && hasPreviousValue( entry ) ) {
|
|
345
|
+
shares.push( entry.previousShare );
|
|
346
|
+
}
|
|
347
|
+
for ( const share of shares ) {
|
|
348
|
+
const side = share < 0 ? 'negative' : 'positive';
|
|
349
|
+
scale[ side ] = Math.max( scale[ side ], Math.abs( share ) );
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return scale.negative > 0 ? scale : undefined;
|
|
353
|
+
}, [ resolvedData, withComparisonBars ] );
|
|
354
|
+
|
|
270
355
|
// Validate data
|
|
271
356
|
const isDataValid = Boolean( data && data.length > 0 );
|
|
272
357
|
|
|
@@ -300,6 +385,11 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
300
385
|
data
|
|
301
386
|
);
|
|
302
387
|
const shouldFitRows = fitRows && isMeasurable;
|
|
388
|
+
// Comparing adds a delta track so the values and the deltas each line up in a column
|
|
389
|
+
// of their own; with either series hidden the grid keeps its two tracks.
|
|
390
|
+
const showComparisonColumn = withComparison && isComparisonVisible;
|
|
391
|
+
const templateColumns =
|
|
392
|
+
showComparisonColumn && isPrimaryVisible ? 'minmax(0, 1fr) auto auto' : 'minmax(0, 1fr) auto';
|
|
303
393
|
|
|
304
394
|
// Handle empty or undefined data
|
|
305
395
|
if ( ! data || data.length === 0 ) {
|
|
@@ -393,12 +483,12 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
393
483
|
<Grid
|
|
394
484
|
role="list"
|
|
395
485
|
aria-label={ ariaLabel ?? __( 'Leaderboard chart', 'jetpack-charts' ) }
|
|
396
|
-
templateColumns=
|
|
486
|
+
templateColumns={ templateColumns }
|
|
397
487
|
rowGap={ ROW_GAP }
|
|
398
488
|
columnGap={ COLUMN_GAP }
|
|
399
489
|
data-leaderboard-grid
|
|
400
490
|
>
|
|
401
|
-
{
|
|
491
|
+
{ resolvedData.map( ( entry, rowIndex ) => {
|
|
402
492
|
// visibility, not clipping: hidden rows keep their geometry (so a
|
|
403
493
|
// taller container can reveal them) but leave hit testing, focus,
|
|
404
494
|
// and the accessibility tree.
|
|
@@ -406,7 +496,6 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
406
496
|
shouldFitRows && rowIndex >= fittedCount
|
|
407
497
|
? ( { visibility: 'hidden' } as const )
|
|
408
498
|
: undefined;
|
|
409
|
-
const showComparisonColumn = withComparison && isComparisonVisible;
|
|
410
499
|
const hasPreviousPeriodValue = hasPreviousValue( entry );
|
|
411
500
|
const hasDelta = hasDeltaValue( entry );
|
|
412
501
|
const showComparisonValue = showComparisonColumn && hasDelta;
|
|
@@ -426,15 +515,14 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
426
515
|
withOverlayLabel={ withOverlayLabel }
|
|
427
516
|
primaryColor={ resolvedPrimaryColor }
|
|
428
517
|
secondaryColor={ resolvedSecondaryColor }
|
|
518
|
+
divergingScale={ divergingScale }
|
|
429
519
|
isPrimaryVisible={ isPrimaryVisible }
|
|
430
520
|
isComparisonVisible={ isComparisonVisible }
|
|
431
521
|
animation={ animation && ! loading && ! prefersReducedMotion }
|
|
432
522
|
/>
|
|
433
523
|
</Stack>
|
|
434
524
|
|
|
435
|
-
<
|
|
436
|
-
direction="row"
|
|
437
|
-
gap="xs"
|
|
525
|
+
<div
|
|
438
526
|
className={ clsx( styles.valueContainer, {
|
|
439
527
|
[ styles.overlayLabel ]: withOverlayLabel,
|
|
440
528
|
} ) }
|
|
@@ -463,7 +551,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
463
551
|
</VisuallyHidden>
|
|
464
552
|
</Text>
|
|
465
553
|
) }
|
|
466
|
-
</
|
|
554
|
+
</div>
|
|
467
555
|
</>
|
|
468
556
|
);
|
|
469
557
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { LeaderboardEntry } from '../../../types';
|
|
2
|
+
|
|
3
|
+
export type ResolvedLeaderboardEntry = LeaderboardEntry & { currentShare: number };
|
|
4
|
+
|
|
5
|
+
const toShare = ( value: number, max: number ) => ( max === 0 ? 0 : ( value / max ) * 100 );
|
|
6
|
+
|
|
7
|
+
const toDelta = ( currentValue: number, previousValue: number | undefined ) => {
|
|
8
|
+
if ( previousValue == null ) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
if ( previousValue === 0 ) {
|
|
12
|
+
return currentValue === 0 ? 0 : undefined;
|
|
13
|
+
}
|
|
14
|
+
return ( ( currentValue - previousValue ) / Math.abs( previousValue ) ) * 100;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fills in the shares and delta an entry omits, derived from its values.
|
|
19
|
+
*
|
|
20
|
+
* @param data - Leaderboard entries; passed shares and delta win over derived ones.
|
|
21
|
+
* @param options - Resolver options.
|
|
22
|
+
* @param options.includePrevious - Whether previous values count toward the shared max.
|
|
23
|
+
* @param options.clampNegative - Whether a negative share draws as an empty bar instead of below zero.
|
|
24
|
+
* @return Entries with `currentShare` always set.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveLeaderboardEntries(
|
|
27
|
+
data: LeaderboardEntry[],
|
|
28
|
+
{ includePrevious, clampNegative = false }: { includePrevious: boolean; clampNegative?: boolean }
|
|
29
|
+
): ResolvedLeaderboardEntry[] {
|
|
30
|
+
const magnitude = clampNegative ? ( value: number ) => Math.max( value, 0 ) : Math.abs;
|
|
31
|
+
const clamp = ( share: number ) => ( clampNegative ? Math.max( share, 0 ) : share );
|
|
32
|
+
|
|
33
|
+
const max = data.reduce( ( largest, { currentValue, previousValue } ) => {
|
|
34
|
+
const previous = includePrevious && previousValue != null ? magnitude( previousValue ) : 0;
|
|
35
|
+
return Math.max( largest, magnitude( currentValue ), previous );
|
|
36
|
+
}, 0 );
|
|
37
|
+
|
|
38
|
+
return data.map( entry => {
|
|
39
|
+
const passedOrDerivedPrevious =
|
|
40
|
+
entry.previousShare ??
|
|
41
|
+
( entry.previousValue != null ? toShare( entry.previousValue, max ) : undefined );
|
|
42
|
+
const previousShare =
|
|
43
|
+
passedOrDerivedPrevious !== undefined ? clamp( passedOrDerivedPrevious ) : undefined;
|
|
44
|
+
const delta = entry.delta ?? toDelta( entry.currentValue, entry.previousValue );
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
...entry,
|
|
48
|
+
currentShare: clamp( entry.currentShare ?? toShare( entry.currentValue, max ) ),
|
|
49
|
+
...( previousShare !== undefined && { previousShare } ),
|
|
50
|
+
...( delta !== undefined && { delta } ),
|
|
51
|
+
};
|
|
52
|
+
} );
|
|
53
|
+
}
|