@automattic/charts 4.13.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 +24 -29
- package/TOKENS.md +3 -0
- package/dist/index.cjs +149 -1624
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +2 -1
- package/dist/index.d.cts +19 -8
- package/dist/index.d.ts +19 -8
- package/dist/index.js +150 -1292
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +1 -1
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +26 -9
- package/src/charts/leaderboard-chart/private/resolve-leaderboard-entries.ts +53 -0
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +41 -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/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/styles/chart-scope.scss +1 -0
- package/src/types.ts +5 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.14.0",
|
|
4
4
|
"description": "Display charts within Automattic products.",
|
|
5
5
|
"homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"typecheck": "tsgo --noEmit"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@automattic/number-formatters": "^1.
|
|
66
|
+
"@automattic/number-formatters": "^1.3.0",
|
|
67
67
|
"@babel/runtime": "7.29.7",
|
|
68
68
|
"@date-fns/tz": "1.5.0",
|
|
69
69
|
"@react-spring/web": "10.1.2",
|
|
@@ -83,10 +83,10 @@
|
|
|
83
83
|
"@visx/tooltip": "^4.0.0",
|
|
84
84
|
"@visx/vendor": "^4.0.0",
|
|
85
85
|
"@visx/xychart": "^4.0.0",
|
|
86
|
-
"@wordpress/i18n": "^
|
|
87
|
-
"@wordpress/icons": "^
|
|
88
|
-
"@wordpress/theme": "
|
|
89
|
-
"@wordpress/ui": "0.
|
|
86
|
+
"@wordpress/i18n": "^7.0.0",
|
|
87
|
+
"@wordpress/icons": "^19.0.0",
|
|
88
|
+
"@wordpress/theme": "3.0.0",
|
|
89
|
+
"@wordpress/ui": "0.25.0",
|
|
90
90
|
"clsx": "2.1.1",
|
|
91
91
|
"date-fns": "^4.1.0",
|
|
92
92
|
"deepmerge": "4.3.1",
|
|
@@ -111,9 +111,9 @@
|
|
|
111
111
|
"@types/react-dom": "19.3.0",
|
|
112
112
|
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
113
113
|
"@visx/glyph": "4.0.0",
|
|
114
|
-
"@wordpress/components": "
|
|
115
|
-
"@wordpress/element": "
|
|
116
|
-
"@wordpress/private-apis": "
|
|
114
|
+
"@wordpress/components": "43.0.0",
|
|
115
|
+
"@wordpress/element": "10.0.0",
|
|
116
|
+
"@wordpress/private-apis": "3.0.0",
|
|
117
117
|
"babel-jest": "30.5.2",
|
|
118
118
|
"identity-obj-proxy": "^3.0.0",
|
|
119
119
|
"jest": "30.5.2",
|
|
@@ -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;
|
|
@@ -128,6 +132,7 @@ const Bar = ( {
|
|
|
128
132
|
|
|
129
133
|
const bar = (
|
|
130
134
|
<div
|
|
135
|
+
data-testid="leaderboard-chart-bar"
|
|
131
136
|
className={ clsx( styles.bar, {
|
|
132
137
|
[ styles[ 'bar--animated' ] ]: animation,
|
|
133
138
|
[ styles[ 'bar--negative' ] ]: divergingScale && isNegative,
|
|
@@ -167,7 +172,7 @@ const BarWithLabel = ( {
|
|
|
167
172
|
isPrimaryVisible = true,
|
|
168
173
|
isComparisonVisible = true,
|
|
169
174
|
}: {
|
|
170
|
-
entry:
|
|
175
|
+
entry: ResolvedLeaderboardEntry;
|
|
171
176
|
withComparison?: boolean;
|
|
172
177
|
withOverlayLabel?: boolean;
|
|
173
178
|
primaryColor: string;
|
|
@@ -225,6 +230,7 @@ const BarWithLabel = ( {
|
|
|
225
230
|
* @param props.height - Optional height of the chart container in pixels
|
|
226
231
|
* @param props.withComparison - Whether to show comparison data
|
|
227
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
|
|
228
234
|
* @param props.primaryColor - Primary color for current period bars
|
|
229
235
|
* @param props.secondaryColor - Secondary color for comparison period bars
|
|
230
236
|
* @param props.valueFormatter - Custom formatter for values
|
|
@@ -249,6 +255,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
249
255
|
height: propHeight,
|
|
250
256
|
withComparison = false,
|
|
251
257
|
withOverlayLabel = false,
|
|
258
|
+
negativeBars = 'diverge',
|
|
252
259
|
primaryColor,
|
|
253
260
|
secondaryColor,
|
|
254
261
|
valueFormatter = defaultValueFormatter,
|
|
@@ -318,11 +325,21 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
318
325
|
return ! isPrimaryVisible;
|
|
319
326
|
}, [ isPrimaryVisible, isComparisonVisible, withComparison, withOverlayLabel ] );
|
|
320
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
|
+
|
|
321
339
|
// One negative share puts every row on a shared zero point, so the bars stay comparable.
|
|
322
340
|
const divergingScale = useMemo( (): DivergingScale | undefined => {
|
|
323
|
-
const withComparisonBars = withComparison && ! withOverlayLabel;
|
|
324
341
|
const scale = { negative: 0, positive: 0 };
|
|
325
|
-
for ( const entry of
|
|
342
|
+
for ( const entry of resolvedData ) {
|
|
326
343
|
const shares = [ entry.currentShare ];
|
|
327
344
|
if ( withComparisonBars && hasPreviousValue( entry ) ) {
|
|
328
345
|
shares.push( entry.previousShare );
|
|
@@ -333,7 +350,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
333
350
|
}
|
|
334
351
|
}
|
|
335
352
|
return scale.negative > 0 ? scale : undefined;
|
|
336
|
-
}, [
|
|
353
|
+
}, [ resolvedData, withComparisonBars ] );
|
|
337
354
|
|
|
338
355
|
// Validate data
|
|
339
356
|
const isDataValid = Boolean( data && data.length > 0 );
|
|
@@ -471,7 +488,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
471
488
|
columnGap={ COLUMN_GAP }
|
|
472
489
|
data-leaderboard-grid
|
|
473
490
|
>
|
|
474
|
-
{
|
|
491
|
+
{ resolvedData.map( ( entry, rowIndex ) => {
|
|
475
492
|
// visibility, not clipping: hidden rows keep their geometry (so a
|
|
476
493
|
// taller container can reveal them) but leave hit testing, focus,
|
|
477
494
|
// and the accessibility tree.
|
|
@@ -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
|
+
}
|
|
@@ -81,6 +81,22 @@ describe( 'LeaderboardChart', () => {
|
|
|
81
81
|
expect( screen.getByText( '-8%' ) ).toBeInTheDocument();
|
|
82
82
|
} );
|
|
83
83
|
|
|
84
|
+
it( 'derives the delta of entries that pass only values', () => {
|
|
85
|
+
render(
|
|
86
|
+
<LeaderboardChart
|
|
87
|
+
data={ [
|
|
88
|
+
{ id: 'up', label: 'Up', currentValue: 100, previousValue: 80 },
|
|
89
|
+
{ id: 'down', label: 'Down', currentValue: 50, previousValue: 100 },
|
|
90
|
+
] }
|
|
91
|
+
withComparison={ true }
|
|
92
|
+
deltaFormatter={ testDeltaFormatter }
|
|
93
|
+
/>
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect( screen.getByText( '25delta' ) ).toBeInTheDocument();
|
|
97
|
+
expect( screen.getByText( '-50delta' ) ).toBeInTheDocument();
|
|
98
|
+
} );
|
|
99
|
+
|
|
84
100
|
it( 'shows a placeholder when a row has no previous value', () => {
|
|
85
101
|
render(
|
|
86
102
|
<LeaderboardChart
|
|
@@ -170,6 +186,31 @@ describe( 'LeaderboardChart', () => {
|
|
|
170
186
|
expect( screen.queryAllByTestId( 'leaderboard-chart-diverging-row' ) ).toHaveLength( expected );
|
|
171
187
|
} );
|
|
172
188
|
|
|
189
|
+
it( 'keeps every bar on one side when negative bars clamp', () => {
|
|
190
|
+
const data = [
|
|
191
|
+
{ ...mockData[ 0 ], currentShare: undefined, currentValue: -40 },
|
|
192
|
+
mockData[ 1 ],
|
|
193
|
+
];
|
|
194
|
+
render( <LeaderboardChart data={ data } withComparison negativeBars="clamp" /> );
|
|
195
|
+
|
|
196
|
+
expect( screen.queryAllByTestId( 'leaderboard-chart-diverging-row' ) ).toHaveLength( 0 );
|
|
197
|
+
} );
|
|
198
|
+
|
|
199
|
+
it.each( [
|
|
200
|
+
[ 'comparison bars draw', false, /^calc\(50%/ ],
|
|
201
|
+
[ 'the overlay label hides comparison bars', true, /^calc\(100%/ ],
|
|
202
|
+
] )( 'sizes the current bar against the max when %s', ( _, withOverlayLabel, expected ) => {
|
|
203
|
+
const data = [
|
|
204
|
+
{ id: 'a', label: 'A', currentValue: 50, previousValue: 100 },
|
|
205
|
+
{ id: 'b', label: 'B', currentValue: 25, previousValue: 10 },
|
|
206
|
+
] as LeaderboardEntry[];
|
|
207
|
+
render(
|
|
208
|
+
<LeaderboardChart data={ data } withComparison withOverlayLabel={ withOverlayLabel } />
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
expect( screen.getAllByTestId( 'leaderboard-chart-bar' )[ 0 ].style.width ).toMatch( expected );
|
|
212
|
+
} );
|
|
213
|
+
|
|
173
214
|
it( 'shows custom label when provided', () => {
|
|
174
215
|
render(
|
|
175
216
|
<LeaderboardChart
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { resolveLeaderboardEntries } from '../private/resolve-leaderboard-entries';
|
|
2
|
+
import type { LeaderboardEntry } from '../../../types';
|
|
3
|
+
|
|
4
|
+
type EntryInput = Partial< LeaderboardEntry > & { currentValue: number };
|
|
5
|
+
|
|
6
|
+
const toEntries = ( rows: EntryInput[] ) =>
|
|
7
|
+
rows.map( ( row, index ) => ( {
|
|
8
|
+
id: `row-${ index }`,
|
|
9
|
+
label: `Row ${ index }`,
|
|
10
|
+
...row,
|
|
11
|
+
} ) ) as LeaderboardEntry[];
|
|
12
|
+
|
|
13
|
+
describe( 'resolveLeaderboardEntries', () => {
|
|
14
|
+
const comparisonRows = [
|
|
15
|
+
{ currentValue: 50, previousValue: 100 },
|
|
16
|
+
{ currentValue: 25, previousValue: 40 },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
it( 'derives both shares and delta against one max across both periods', () => {
|
|
20
|
+
const result = resolveLeaderboardEntries( toEntries( comparisonRows ), {
|
|
21
|
+
includePrevious: true,
|
|
22
|
+
} );
|
|
23
|
+
|
|
24
|
+
expect(
|
|
25
|
+
result.map( ( { currentShare, previousShare, delta } ) => ( {
|
|
26
|
+
currentShare,
|
|
27
|
+
previousShare,
|
|
28
|
+
delta,
|
|
29
|
+
} ) )
|
|
30
|
+
).toEqual( [
|
|
31
|
+
{ currentShare: 50, previousShare: 100, delta: -50 },
|
|
32
|
+
{ currentShare: 25, previousShare: 40, delta: -37.5 },
|
|
33
|
+
] );
|
|
34
|
+
} );
|
|
35
|
+
|
|
36
|
+
it( 'lets a negative previous value set the max', () => {
|
|
37
|
+
const [ entry ] = resolveLeaderboardEntries(
|
|
38
|
+
toEntries( [ { currentValue: 10, previousValue: -40 } ] ),
|
|
39
|
+
{ includePrevious: true }
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect( entry ).toMatchObject( { currentShare: 25, previousShare: -100 } );
|
|
43
|
+
} );
|
|
44
|
+
|
|
45
|
+
it( 'ignores previous values for the max when comparison bars are not drawn', () => {
|
|
46
|
+
const result = resolveLeaderboardEntries( toEntries( comparisonRows ), {
|
|
47
|
+
includePrevious: false,
|
|
48
|
+
} );
|
|
49
|
+
|
|
50
|
+
expect( result.map( ( { currentShare } ) => currentShare ) ).toEqual( [ 100, 50 ] );
|
|
51
|
+
} );
|
|
52
|
+
|
|
53
|
+
it( 'keeps passed fields and derives the rest', () => {
|
|
54
|
+
const [ entry ] = resolveLeaderboardEntries(
|
|
55
|
+
toEntries( [ { currentValue: 50, currentShare: 10, previousValue: 100, delta: 7 } ] ),
|
|
56
|
+
{ includePrevious: true }
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
expect( entry ).toMatchObject( { currentShare: 10, delta: 7, previousShare: 100 } );
|
|
60
|
+
} );
|
|
61
|
+
|
|
62
|
+
it( 'derives a missing share from the max over every row, passed or not', () => {
|
|
63
|
+
const result = resolveLeaderboardEntries(
|
|
64
|
+
toEntries( [ { currentValue: 10, currentShare: 100 }, { currentValue: 20 } ] ),
|
|
65
|
+
{ includePrevious: false }
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect( result.map( ( { currentShare } ) => currentShare ) ).toEqual( [ 100, 100 ] );
|
|
69
|
+
} );
|
|
70
|
+
|
|
71
|
+
it( 'keeps the sign of negative values', () => {
|
|
72
|
+
const result = resolveLeaderboardEntries(
|
|
73
|
+
toEntries( [ { currentValue: -30 }, { currentValue: 60 } ] ),
|
|
74
|
+
{ includePrevious: false }
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect( result.map( ( { currentShare } ) => currentShare ) ).toEqual( [ -50, 100 ] );
|
|
78
|
+
} );
|
|
79
|
+
|
|
80
|
+
it( 'clamps negative shares to 0 and sizes the rest against the largest positive value', () => {
|
|
81
|
+
const result = resolveLeaderboardEntries(
|
|
82
|
+
toEntries( [
|
|
83
|
+
{ currentValue: -90, previousValue: 30 },
|
|
84
|
+
{ currentValue: 60, previousValue: -10, previousShare: -20 },
|
|
85
|
+
] ),
|
|
86
|
+
{ includePrevious: true, clampNegative: true }
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect(
|
|
90
|
+
result.map( ( { currentShare, previousShare, delta } ) => ( {
|
|
91
|
+
currentShare,
|
|
92
|
+
previousShare,
|
|
93
|
+
delta,
|
|
94
|
+
} ) )
|
|
95
|
+
).toEqual( [
|
|
96
|
+
{ currentShare: 0, previousShare: 50, delta: -400 },
|
|
97
|
+
{ currentShare: 100, previousShare: 0, delta: 700 },
|
|
98
|
+
] );
|
|
99
|
+
} );
|
|
100
|
+
|
|
101
|
+
it.each( [
|
|
102
|
+
{ currentValue: 5, previousValue: -10, expected: 150 },
|
|
103
|
+
{ currentValue: 0, previousValue: 0, expected: 0 },
|
|
104
|
+
{ currentValue: 5, previousValue: 0, expected: undefined },
|
|
105
|
+
{ currentValue: 5, previousValue: undefined, expected: undefined },
|
|
106
|
+
] )(
|
|
107
|
+
'derives delta $expected for current $currentValue and previous $previousValue',
|
|
108
|
+
( { currentValue, previousValue, expected } ) => {
|
|
109
|
+
const [ entry ] = resolveLeaderboardEntries(
|
|
110
|
+
toEntries( [ { currentValue, previousValue } ] ),
|
|
111
|
+
{ includePrevious: true }
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect( entry.delta ).toBe( expected );
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
it( 'sizes rows without a previous value against the shared max', () => {
|
|
119
|
+
const result = resolveLeaderboardEntries(
|
|
120
|
+
toEntries( [ { currentValue: 50, previousValue: 100 }, { currentValue: 25 } ] ),
|
|
121
|
+
{ includePrevious: true }
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect( result.map( ( { currentShare } ) => currentShare ) ).toEqual( [ 50, 25 ] );
|
|
125
|
+
expect( result[ 1 ] ).not.toHaveProperty( 'previousShare' );
|
|
126
|
+
} );
|
|
127
|
+
|
|
128
|
+
it( 'gives shares of 0 when every value is 0', () => {
|
|
129
|
+
const [ entry ] = resolveLeaderboardEntries(
|
|
130
|
+
toEntries( [ { currentValue: 0, previousValue: 0 } ] ),
|
|
131
|
+
{ includePrevious: true }
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect( entry ).toMatchObject( { currentShare: 0, previousShare: 0, delta: 0 } );
|
|
135
|
+
} );
|
|
136
|
+
|
|
137
|
+
it( 'leaves other fields untouched', () => {
|
|
138
|
+
const onClick = jest.fn();
|
|
139
|
+
const [ entry ] = resolveLeaderboardEntries(
|
|
140
|
+
toEntries( [ { id: 'a', label: 'A', currentValue: 1, onClick } ] ),
|
|
141
|
+
{ includePrevious: false }
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect( entry ).toMatchObject( { id: 'a', label: 'A', onClick } );
|
|
145
|
+
} );
|
|
146
|
+
} );
|
|
@@ -30,6 +30,12 @@ export interface LeaderboardChartProps extends Pick<
|
|
|
30
30
|
*/
|
|
31
31
|
withOverlayLabel?: boolean;
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* How a negative value draws: `diverge` puts every row on a shared zero point, `clamp` draws it as an empty bar.
|
|
35
|
+
* @default 'diverge'
|
|
36
|
+
*/
|
|
37
|
+
negativeBars?: 'diverge' | 'clamp';
|
|
38
|
+
|
|
33
39
|
/**
|
|
34
40
|
* Primary color for current period bars
|
|
35
41
|
*/
|
|
@@ -217,24 +217,27 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
217
217
|
|
|
218
218
|
// Create event handlers to avoid inline arrow functions
|
|
219
219
|
const createClickHandler = useCallback(
|
|
220
|
-
( seriesLabels: string[], itemInteractive: boolean ) => {
|
|
220
|
+
( seriesLabels: string[], itemInteractive: boolean, onToggle?: () => void ) => {
|
|
221
221
|
if ( ! itemInteractive ) {
|
|
222
222
|
return undefined;
|
|
223
223
|
}
|
|
224
|
-
return () => handleLegendClick( seriesLabels );
|
|
224
|
+
return onToggle ? () => onToggle() : () => handleLegendClick( seriesLabels );
|
|
225
225
|
},
|
|
226
226
|
[ handleLegendClick ]
|
|
227
227
|
);
|
|
228
228
|
|
|
229
229
|
const createKeyDownHandler = useCallback(
|
|
230
|
-
( seriesLabels: string[], itemInteractive: boolean ) => {
|
|
230
|
+
( seriesLabels: string[], itemInteractive: boolean, onToggle?: () => void ) => {
|
|
231
231
|
if ( ! itemInteractive ) {
|
|
232
232
|
return undefined;
|
|
233
233
|
}
|
|
234
|
+
const toggle = onToggle ?? ( () => handleLegendClick( seriesLabels ) );
|
|
234
235
|
return ( event: KeyboardEvent ) => {
|
|
235
236
|
if ( event.key === 'Enter' || event.key === ' ' ) {
|
|
236
237
|
event.preventDefault();
|
|
237
|
-
|
|
238
|
+
if ( ! event.repeat ) {
|
|
239
|
+
toggle();
|
|
240
|
+
}
|
|
238
241
|
}
|
|
239
242
|
};
|
|
240
243
|
},
|
|
@@ -278,11 +281,13 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
278
281
|
const seriesLabels = matchedItem?.seriesLabels?.length
|
|
279
282
|
? matchedItem.seriesLabels
|
|
280
283
|
: [ label.text ];
|
|
281
|
-
const visible =
|
|
282
|
-
matchedItem?.interactive === false || isSeriesVisible( seriesLabels[ 0 ] );
|
|
283
284
|
const itemInteractive = interactive && matchedItem?.interactive !== false;
|
|
284
|
-
const
|
|
285
|
-
const
|
|
285
|
+
const onToggle = itemInteractive ? matchedItem?.onToggle : undefined;
|
|
286
|
+
const visible = matchedItem?.onToggle
|
|
287
|
+
? matchedItem.pressed
|
|
288
|
+
: matchedItem?.interactive === false || isSeriesVisible( seriesLabels[ 0 ] );
|
|
289
|
+
const handleClick = createClickHandler( seriesLabels, itemInteractive, onToggle );
|
|
290
|
+
const handleKeyDown = createKeyDownHandler( seriesLabels, itemInteractive, onToggle );
|
|
286
291
|
|
|
287
292
|
return (
|
|
288
293
|
<LegendItem
|
|
@@ -900,6 +900,131 @@ describe( 'BaseLegend', () => {
|
|
|
900
900
|
} );
|
|
901
901
|
} );
|
|
902
902
|
|
|
903
|
+
describe( 'Controlled items', () => {
|
|
904
|
+
const VisibilityProbe = () => {
|
|
905
|
+
const { isSeriesVisible } = useGlobalChartsContext();
|
|
906
|
+
return (
|
|
907
|
+
<output data-testid="views-visible">
|
|
908
|
+
{ String( isSeriesVisible( 'test-chart', 'Views' ) ) }
|
|
909
|
+
</output>
|
|
910
|
+
);
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
it.each( [ false, true ] )( 'takes the pressed state from the item (%s)', pressed => {
|
|
914
|
+
render(
|
|
915
|
+
<GlobalChartsProvider>
|
|
916
|
+
<BaseLegend
|
|
917
|
+
items={ [ { label: 'Previous period', color: '#000', pressed, onToggle: jest.fn() } ] }
|
|
918
|
+
interactive
|
|
919
|
+
chartId="test-chart"
|
|
920
|
+
/>
|
|
921
|
+
</GlobalChartsProvider>
|
|
922
|
+
);
|
|
923
|
+
|
|
924
|
+
expect( screen.getByRole( 'button', { name: /Previous period/ } ) ).toHaveAttribute(
|
|
925
|
+
'aria-pressed',
|
|
926
|
+
String( pressed )
|
|
927
|
+
);
|
|
928
|
+
} );
|
|
929
|
+
|
|
930
|
+
it.each( [ 'click', 'Enter', ' ' ] )(
|
|
931
|
+
'calls onToggle on %s and leaves series visibility alone',
|
|
932
|
+
async interaction => {
|
|
933
|
+
const user = userEvent.setup();
|
|
934
|
+
const onToggle = jest.fn();
|
|
935
|
+
render(
|
|
936
|
+
<GlobalChartsProvider>
|
|
937
|
+
<BaseLegend
|
|
938
|
+
items={ [
|
|
939
|
+
{
|
|
940
|
+
label: 'Previous period',
|
|
941
|
+
color: '#000',
|
|
942
|
+
seriesLabels: [ 'Views' ],
|
|
943
|
+
pressed: false,
|
|
944
|
+
onToggle,
|
|
945
|
+
},
|
|
946
|
+
] }
|
|
947
|
+
interactive
|
|
948
|
+
chartId="test-chart"
|
|
949
|
+
/>
|
|
950
|
+
<VisibilityProbe />
|
|
951
|
+
</GlobalChartsProvider>
|
|
952
|
+
);
|
|
953
|
+
|
|
954
|
+
const button = screen.getByRole( 'button', { name: /Previous period/ } );
|
|
955
|
+
if ( interaction === 'click' ) {
|
|
956
|
+
await user.click( button );
|
|
957
|
+
} else {
|
|
958
|
+
button.focus();
|
|
959
|
+
await user.keyboard( interaction === ' ' ? ' ' : '{Enter}' );
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
expect( onToggle ).toHaveBeenCalledTimes( 1 );
|
|
963
|
+
expect( onToggle ).toHaveBeenCalledWith();
|
|
964
|
+
expect( screen.getByTestId( 'views-visible' ) ).toHaveTextContent( 'true' );
|
|
965
|
+
}
|
|
966
|
+
);
|
|
967
|
+
|
|
968
|
+
it( 'ignores key repeats while Enter is held', async () => {
|
|
969
|
+
const user = userEvent.setup();
|
|
970
|
+
const onToggle = jest.fn();
|
|
971
|
+
render(
|
|
972
|
+
<GlobalChartsProvider>
|
|
973
|
+
<BaseLegend
|
|
974
|
+
items={ [ { label: 'Previous period', color: '#000', pressed: false, onToggle } ] }
|
|
975
|
+
interactive
|
|
976
|
+
chartId="test-chart"
|
|
977
|
+
/>
|
|
978
|
+
</GlobalChartsProvider>
|
|
979
|
+
);
|
|
980
|
+
|
|
981
|
+
screen.getByRole( 'button', { name: /Previous period/ } ).focus();
|
|
982
|
+
await user.keyboard( '{Enter>3/}' );
|
|
983
|
+
|
|
984
|
+
expect( onToggle ).toHaveBeenCalledTimes( 1 );
|
|
985
|
+
} );
|
|
986
|
+
|
|
987
|
+
const renderStaticControlledItem = ( pressed: boolean ) => {
|
|
988
|
+
const onToggle = jest.fn();
|
|
989
|
+
render(
|
|
990
|
+
<GlobalChartsProvider>
|
|
991
|
+
<BaseLegend
|
|
992
|
+
items={ [
|
|
993
|
+
{
|
|
994
|
+
label: 'Previous period',
|
|
995
|
+
color: '#000',
|
|
996
|
+
seriesLabels: [ 'Views' ],
|
|
997
|
+
pressed,
|
|
998
|
+
onToggle,
|
|
999
|
+
},
|
|
1000
|
+
] }
|
|
1001
|
+
interactive={ false }
|
|
1002
|
+
chartId="test-chart"
|
|
1003
|
+
/>
|
|
1004
|
+
</GlobalChartsProvider>
|
|
1005
|
+
);
|
|
1006
|
+
return { item: screen.getByTestId( 'legend-item' ), onToggle };
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
it( 'stays static in a legend that is not interactive', async () => {
|
|
1010
|
+
const user = userEvent.setup();
|
|
1011
|
+
const { item, onToggle } = renderStaticControlledItem( true );
|
|
1012
|
+
|
|
1013
|
+
expect( screen.queryByRole( 'button' ) ).not.toBeInTheDocument();
|
|
1014
|
+
expect( item ).not.toHaveClass( 'legend-item--inactive' );
|
|
1015
|
+
expect( item ).not.toHaveAttribute( 'aria-label' );
|
|
1016
|
+
await user.click( item );
|
|
1017
|
+
expect( onToggle ).not.toHaveBeenCalled();
|
|
1018
|
+
} );
|
|
1019
|
+
|
|
1020
|
+
it( 'dims an item that is not pressed in a legend that is not interactive', () => {
|
|
1021
|
+
const { item } = renderStaticControlledItem( false );
|
|
1022
|
+
|
|
1023
|
+
expect( item ).toHaveClass( 'legend-item--inactive' );
|
|
1024
|
+
expect( item ).toHaveAttribute( 'aria-label', 'Previous period: hidden' );
|
|
1025
|
+
} );
|
|
1026
|
+
} );
|
|
1027
|
+
|
|
903
1028
|
describe( 'Non-interactive legend reflects hidden state', () => {
|
|
904
1029
|
const HiddenSeriesHarness = ( { interactive }: { interactive: boolean } ) => {
|
|
905
1030
|
const context = useGlobalChartsContext();
|
|
@@ -65,4 +65,12 @@ export type BaseLegendItem = {
|
|
|
65
65
|
interactive?: boolean;
|
|
66
66
|
/** Label of the series this static item describes; the item is left out while it is hidden. */
|
|
67
67
|
describesSeries?: string;
|
|
68
|
-
}
|
|
68
|
+
} & (
|
|
69
|
+
| {
|
|
70
|
+
/** Pressed state of the toggle. */
|
|
71
|
+
pressed: boolean;
|
|
72
|
+
/** Makes an interactive legend item a toggle the consumer controls, instead of a series toggle. */
|
|
73
|
+
onToggle: () => void;
|
|
74
|
+
}
|
|
75
|
+
| { pressed?: never; onToggle?: never }
|
|
76
|
+
);
|
|
@@ -87,6 +87,7 @@
|
|
|
87
87
|
|
|
88
88
|
--a8c-charts-dimension-leaderboard-row-gap: var(--wpds-dimension-gap-md);
|
|
89
89
|
--a8c-charts-dimension-leaderboard-column-gap: var(--wpds-dimension-gap-xs);
|
|
90
|
+
--a8c-charts-dimension-leaderboard-label-inset: var(--wpds-dimension-padding-sm);
|
|
90
91
|
--a8c-charts-dimension-legend-column-gap: var(--wpds-dimension-gap-lg);
|
|
91
92
|
--a8c-charts-dimension-legend-row-gap: var(--wpds-dimension-gap-sm);
|
|
92
93
|
}
|
package/src/types.ts
CHANGED
|
@@ -214,20 +214,18 @@ type LeaderboardEntryFields = {
|
|
|
214
214
|
previousValue?: number;
|
|
215
215
|
|
|
216
216
|
/**
|
|
217
|
-
* Width of current bar, as
|
|
218
|
-
* extends the bar from a shared zero point toward the inline start, for every row.
|
|
217
|
+
* Width of current bar, as %. Derived from the values when omitted; a passed value wins.
|
|
218
|
+
* A negative share extends the bar from a shared zero point toward the inline start, for every row.
|
|
219
219
|
*/
|
|
220
|
-
currentShare
|
|
220
|
+
currentShare?: number;
|
|
221
221
|
|
|
222
222
|
/**
|
|
223
|
-
* Width of previous bar, as
|
|
224
|
-
* row has no matching comparison-period value.
|
|
223
|
+
* Width of previous bar, as %. Derived from `previousValue` when omitted; a passed value wins.
|
|
225
224
|
*/
|
|
226
225
|
previousShare?: number;
|
|
227
226
|
|
|
228
227
|
/**
|
|
229
|
-
*
|
|
230
|
-
* as when the row has no comparison value or its previous value is zero.
|
|
228
|
+
* Percentage change. Derived from the values when omitted; a passed value wins.
|
|
231
229
|
*/
|
|
232
230
|
delta?: number;
|
|
233
231
|
|