@automattic/charts 4.2.0 → 4.4.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 +533 -127
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -2
- package/dist/index.d.ts +27 -2
- package/dist/index.js +535 -129
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.tsx +42 -10
- package/src/charts/area-chart/private/validate-data.ts +2 -3
- package/src/charts/area-chart/test/area-chart.test.tsx +72 -1
- package/src/charts/bar-chart/bar-chart.tsx +135 -96
- package/src/charts/bar-chart/index.ts +1 -1
- package/src/charts/bar-chart/private/band-highlight.tsx +3 -1
- package/src/charts/bar-chart/private/band-tooltip.tsx +1 -1
- package/src/charts/bar-chart/private/classified-bar-series.tsx +85 -0
- package/src/charts/bar-chart/private/comparison-bars-geometry.ts +3 -6
- package/src/charts/bar-chart/private/index.ts +2 -0
- package/src/charts/bar-chart/private/test/band-highlight.test.tsx +28 -2
- package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +2 -3
- package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +12 -39
- package/src/charts/bar-chart/private/value-domain.ts +90 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +614 -10
- package/src/charts/bar-chart/types.ts +43 -0
- package/src/charts/line-chart/line-chart.tsx +148 -35
- package/src/charts/line-chart/private/index.ts +1 -0
- package/src/charts/line-chart/private/line-chart-glyph.tsx +17 -3
- package/src/charts/line-chart/private/nearest-pointer-events.tsx +155 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +604 -9
- package/src/charts/line-chart/types.ts +2 -1
- package/src/charts/private/readings.ts +34 -0
- package/src/charts/private/test/readings.test.ts +29 -0
- package/src/charts/private/test/whole-number-ticks.test.tsx +57 -0
- package/src/charts/private/whole-number-ticks.tsx +79 -0
- package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +124 -0
- package/src/components/legend/hooks/use-chart-legend-items.ts +60 -2
- package/src/components/legend/private/base-legend.tsx +36 -21
- package/src/components/legend/test/legend.test.tsx +84 -0
- package/src/components/legend/types.ts +2 -0
- package/src/components/legend/utils/index.ts +0 -1
- package/src/components/tooltip/accessible-tooltip.tsx +1 -1
- package/src/hooks/test/use-chart-margin.test.tsx +35 -0
- package/src/hooks/use-chart-margin.tsx +7 -2
- package/src/providers/chart-context/global-charts-provider.tsx +1 -1
- package/src/providers/chart-context/test/chart-context.test.tsx +4 -4
- package/src/types.ts +8 -0
- package/src/components/legend/utils/label-transform-factory.ts +0 -24
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { formatReading, isInvalidReading } from '../readings';
|
|
2
|
+
|
|
3
|
+
describe( 'isInvalidReading', () => {
|
|
4
|
+
test( 'accepts a number, including zero', () => {
|
|
5
|
+
expect( isInvalidReading( 0, { allowMissing: false } ) ).toBe( false );
|
|
6
|
+
expect( isInvalidReading( 42, { allowMissing: false } ) ).toBe( false );
|
|
7
|
+
} );
|
|
8
|
+
|
|
9
|
+
test( 'rejects undefined and NaN whether or not missing readings are allowed', () => {
|
|
10
|
+
expect( isInvalidReading( undefined, { allowMissing: true } ) ).toBe( true );
|
|
11
|
+
expect( isInvalidReading( NaN, { allowMissing: true } ) ).toBe( true );
|
|
12
|
+
} );
|
|
13
|
+
|
|
14
|
+
test( 'accepts null only where missing readings are allowed', () => {
|
|
15
|
+
expect( isInvalidReading( null, { allowMissing: true } ) ).toBe( false );
|
|
16
|
+
expect( isInvalidReading( null, { allowMissing: false } ) ).toBe( true );
|
|
17
|
+
} );
|
|
18
|
+
} );
|
|
19
|
+
|
|
20
|
+
describe( 'formatReading', () => {
|
|
21
|
+
test( 'reads a missing value as No data', () => {
|
|
22
|
+
expect( formatReading( null ) ).toBe( 'No data' );
|
|
23
|
+
expect( formatReading( undefined ) ).toBe( 'No data' );
|
|
24
|
+
} );
|
|
25
|
+
|
|
26
|
+
test( 'formats a real zero as zero', () => {
|
|
27
|
+
expect( formatReading( 0 ) ).toBe( '0' );
|
|
28
|
+
} );
|
|
29
|
+
} );
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createScale } from '@visx/scale';
|
|
2
|
+
import { getWholeNumberTickValues, hasOnlyWholeNumbers } from '../whole-number-ticks';
|
|
3
|
+
import type { SeriesData } from '../../../types';
|
|
4
|
+
|
|
5
|
+
const linear = ( domain: [ number, number ] ) =>
|
|
6
|
+
createScale( { type: 'linear', domain, range: [ 100, 0 ] } );
|
|
7
|
+
|
|
8
|
+
const series = ( ...values: Array< number | null > ): SeriesData => ( {
|
|
9
|
+
label: 'A',
|
|
10
|
+
data: values.map( ( value, i ) => ( { date: new Date( 2024, i, 1 ), value } ) ),
|
|
11
|
+
} );
|
|
12
|
+
|
|
13
|
+
describe( 'getWholeNumberTickValues', () => {
|
|
14
|
+
it( 'keeps only the whole ticks of a range smaller than the tick count', () => {
|
|
15
|
+
expect( getWholeNumberTickValues( linear( [ 0, 1 ] ), 4 ) ).toEqual( [ 0, 1 ] );
|
|
16
|
+
expect( getWholeNumberTickValues( linear( [ 0, 2 ] ), 4 ) ).toEqual( [ 0, 1, 2 ] );
|
|
17
|
+
} );
|
|
18
|
+
|
|
19
|
+
it( 'leaves a scale whose ticks are already whole to visx', () => {
|
|
20
|
+
expect( getWholeNumberTickValues( linear( [ 0, 1000 ] ), 4 ) ).toBeUndefined();
|
|
21
|
+
} );
|
|
22
|
+
|
|
23
|
+
it( 'leaves non-numeric scales to visx', () => {
|
|
24
|
+
const band = createScale( { type: 'band', domain: [ 'a', 'b' ], range: [ 0, 100 ] } );
|
|
25
|
+
const time = createScale( {
|
|
26
|
+
type: 'time',
|
|
27
|
+
domain: [ new Date( 2024, 0, 1 ), new Date( 2024, 0, 2 ) ],
|
|
28
|
+
range: [ 0, 100 ],
|
|
29
|
+
} );
|
|
30
|
+
expect( getWholeNumberTickValues( band, 4 ) ).toBeUndefined();
|
|
31
|
+
expect( getWholeNumberTickValues( time, 4 ) ).toBeUndefined();
|
|
32
|
+
expect( getWholeNumberTickValues( undefined, 4 ) ).toBeUndefined();
|
|
33
|
+
} );
|
|
34
|
+
|
|
35
|
+
it( 'leaves a range with no whole tick to visx', () => {
|
|
36
|
+
expect( getWholeNumberTickValues( linear( [ 0.2, 0.8 ] ), 4 ) ).toBeUndefined();
|
|
37
|
+
} );
|
|
38
|
+
|
|
39
|
+
it( 'leaves a range with only one whole tick to visx', () => {
|
|
40
|
+
expect( getWholeNumberTickValues( linear( [ 0, 0.8 ] ), 4 ) ).toBeUndefined();
|
|
41
|
+
} );
|
|
42
|
+
} );
|
|
43
|
+
|
|
44
|
+
describe( 'hasOnlyWholeNumbers', () => {
|
|
45
|
+
it( 'is true when every reading is whole, skipping missing ones', () => {
|
|
46
|
+
expect( hasOnlyWholeNumbers( [ series( 0, 1, null, 3 ) ] ) ).toBe( true );
|
|
47
|
+
} );
|
|
48
|
+
|
|
49
|
+
it( 'is false when any reading has a fraction', () => {
|
|
50
|
+
expect( hasOnlyWholeNumbers( [ series( 0, 1 ), series( 0.5 ) ] ) ).toBe( false );
|
|
51
|
+
} );
|
|
52
|
+
|
|
53
|
+
it( 'is false when there is no reading at all', () => {
|
|
54
|
+
expect( hasOnlyWholeNumbers( [ series( null, null ) ] ) ).toBe( false );
|
|
55
|
+
expect( hasOnlyWholeNumbers( [] ) ).toBe( false );
|
|
56
|
+
} );
|
|
57
|
+
} );
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { DataContext } from '@visx/xychart';
|
|
2
|
+
import { useContext } from 'react';
|
|
3
|
+
import { isReading } from './readings';
|
|
4
|
+
import type { SeriesData } from '../../types';
|
|
5
|
+
import type { ReactNode } from 'react';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether every reading in the series is a whole number.
|
|
9
|
+
*
|
|
10
|
+
* @param series - The series visx renders.
|
|
11
|
+
* @return True when at least one reading exists and none has a fraction.
|
|
12
|
+
*/
|
|
13
|
+
export const hasOnlyWholeNumbers = ( series: SeriesData[] ): boolean => {
|
|
14
|
+
let sawReading = false;
|
|
15
|
+
for ( const { data } of series ) {
|
|
16
|
+
for ( const point of data ) {
|
|
17
|
+
const value = point?.value;
|
|
18
|
+
if ( ! isReading( value ) ) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if ( ! Number.isInteger( value ) ) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
sawReading = true;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return sawReading;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The scale's ticks with the fractional ones dropped.
|
|
32
|
+
*
|
|
33
|
+
* d3 steps by 1, 2 or 5 times a power of ten, so every whole number inside the domain is one of its ticks.
|
|
34
|
+
*
|
|
35
|
+
* @param scale - The value scale visx built.
|
|
36
|
+
* @param count - Ticks to ask the scale for; undefined uses the scale's own default, as visx does.
|
|
37
|
+
* @return Whole-number ticks, or undefined when visx's own ticks need no change.
|
|
38
|
+
*/
|
|
39
|
+
export const getWholeNumberTickValues = (
|
|
40
|
+
scale: unknown,
|
|
41
|
+
count?: number
|
|
42
|
+
): number[] | undefined => {
|
|
43
|
+
const ticks = ( scale as { ticks?: ( count?: number ) => unknown[] } )?.ticks?.( count );
|
|
44
|
+
if ( ! ticks?.every( ( tick ): tick is number => typeof tick === 'number' ) ) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const whole = ticks.filter( tick => Number.isInteger( tick ) );
|
|
48
|
+
return whole.length > 1 && whole.length < ticks.length ? whole : undefined;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type WholeNumberTicksProps = {
|
|
52
|
+
axis: 'x' | 'y';
|
|
53
|
+
numTicks?: number;
|
|
54
|
+
enabled: boolean;
|
|
55
|
+
children: ( tickValues: number[] | undefined ) => ReactNode;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Hands its children the whole-number ticks of the chart's value scale.
|
|
60
|
+
*
|
|
61
|
+
* @param props - Component props.
|
|
62
|
+
* @param props.axis - Which scale carries the values.
|
|
63
|
+
* @param props.numTicks - Ticks to ask the scale for.
|
|
64
|
+
* @param props.enabled - Whether the plotted values are all whole numbers and the caller pinned neither the ticks nor the value domain.
|
|
65
|
+
* @param props.children - Renders the axis and grid with the tick values.
|
|
66
|
+
* @return The rendered children.
|
|
67
|
+
*/
|
|
68
|
+
export const WholeNumberTicks = ( {
|
|
69
|
+
axis,
|
|
70
|
+
numTicks,
|
|
71
|
+
enabled,
|
|
72
|
+
children,
|
|
73
|
+
}: WholeNumberTicksProps ) => {
|
|
74
|
+
const { xScale, yScale } = useContext( DataContext );
|
|
75
|
+
const tickValues = enabled
|
|
76
|
+
? getWholeNumberTickValues( axis === 'x' ? xScale : yScale, numTicks )
|
|
77
|
+
: undefined;
|
|
78
|
+
return <>{ children( tickValues ) }</>;
|
|
79
|
+
};
|
|
@@ -247,6 +247,130 @@ describe( 'useChartLegendItems', () => {
|
|
|
247
247
|
} );
|
|
248
248
|
} );
|
|
249
249
|
|
|
250
|
+
describe( 'Comparison item (SeriesData)', () => {
|
|
251
|
+
const comparisonData: SeriesData[] = [
|
|
252
|
+
{ label: 'Views', group: 'views', data: [ { label: 'Mon', value: 100 } ] },
|
|
253
|
+
{
|
|
254
|
+
label: 'Views — previous',
|
|
255
|
+
group: 'views',
|
|
256
|
+
options: { type: 'comparison' as const, seriesLineStyle: { strokeDasharray: '4 4' } },
|
|
257
|
+
data: [ { label: 'Mon', value: 90 } ],
|
|
258
|
+
},
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
test( 'is off by default', () => {
|
|
262
|
+
const { result } = renderHook(
|
|
263
|
+
() => useChartLegendItems( comparisonData, { collapseGroups: true } ),
|
|
264
|
+
{ wrapper }
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
expect( result.current.map( item => item.label ) ).toEqual( [ 'Views' ] );
|
|
268
|
+
} );
|
|
269
|
+
|
|
270
|
+
test( 'appends a static item styled like the comparison series', () => {
|
|
271
|
+
const { result } = renderHook(
|
|
272
|
+
() =>
|
|
273
|
+
useChartLegendItems(
|
|
274
|
+
comparisonData,
|
|
275
|
+
{ collapseGroups: true, comparisonItem: true },
|
|
276
|
+
'line'
|
|
277
|
+
),
|
|
278
|
+
{ wrapper }
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
expect( result.current.map( item => item.label ) ).toEqual( [
|
|
282
|
+
'Views',
|
|
283
|
+
'Comparison period',
|
|
284
|
+
] );
|
|
285
|
+
expect( result.current[ 1 ].interactive ).toBe( false );
|
|
286
|
+
expect( result.current[ 1 ].shapeStyle ).toMatchObject( { strokeDasharray: '4 4' } );
|
|
287
|
+
expect( result.current[ 1 ].color ).toBe( result.current[ 0 ].color );
|
|
288
|
+
} );
|
|
289
|
+
|
|
290
|
+
test( 'uses a string option as the label', () => {
|
|
291
|
+
const { result } = renderHook(
|
|
292
|
+
() =>
|
|
293
|
+
useChartLegendItems( comparisonData, {
|
|
294
|
+
collapseGroups: true,
|
|
295
|
+
comparisonItem: 'Previous period',
|
|
296
|
+
} ),
|
|
297
|
+
{ wrapper }
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
expect( result.current.map( item => item.label ) ).toEqual( [ 'Views', 'Previous period' ] );
|
|
301
|
+
} );
|
|
302
|
+
|
|
303
|
+
test( 'adds nothing when the comparison series already has its own item', () => {
|
|
304
|
+
const { result } = renderHook(
|
|
305
|
+
() => useChartLegendItems( comparisonData, { comparisonItem: true } ),
|
|
306
|
+
{ wrapper }
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
expect( result.current.map( item => item.label ) ).toEqual( [ 'Views', 'Views — previous' ] );
|
|
310
|
+
} );
|
|
311
|
+
|
|
312
|
+
test( 'styles a current-period series by its own line styles, not the theme index', () => {
|
|
313
|
+
const theme = {
|
|
314
|
+
seriesLineStyles: [ { strokeWidth: 2 } ],
|
|
315
|
+
legend: { shapeStyles: [ {}, { strokeDasharray: '2 2' } ] },
|
|
316
|
+
};
|
|
317
|
+
const themedWrapper = ( { children }: { children: ReactNode } ) => (
|
|
318
|
+
<GlobalChartsProvider theme={ theme }>{ children }</GlobalChartsProvider>
|
|
319
|
+
);
|
|
320
|
+
const pairedData: SeriesData[] = [
|
|
321
|
+
{ label: 'Views', group: 'views', data: [ { label: 'Mon', value: 100 } ] },
|
|
322
|
+
{ label: 'Visitors', group: 'visitors', data: [ { label: 'Mon', value: 50 } ] },
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
const { result } = renderHook( () => useChartLegendItems( pairedData, {}, 'line' ), {
|
|
326
|
+
wrapper: themedWrapper,
|
|
327
|
+
} );
|
|
328
|
+
|
|
329
|
+
expect( result.current[ 1 ].shapeStyle ).toEqual( { strokeWidth: 2 } );
|
|
330
|
+
} );
|
|
331
|
+
|
|
332
|
+
test( "takes the first metric's color when several metrics carry a comparison", () => {
|
|
333
|
+
const pairedData: SeriesData[] = [
|
|
334
|
+
{ label: 'Views', group: 'views', data: [ { label: 'Mon', value: 100 } ] },
|
|
335
|
+
{
|
|
336
|
+
label: 'Views — previous',
|
|
337
|
+
group: 'views',
|
|
338
|
+
options: { type: 'comparison' as const },
|
|
339
|
+
data: [ { label: 'Mon', value: 90 } ],
|
|
340
|
+
},
|
|
341
|
+
{ label: 'Visitors', group: 'visitors', data: [ { label: 'Mon', value: 50 } ] },
|
|
342
|
+
{
|
|
343
|
+
label: 'Visitors — previous',
|
|
344
|
+
group: 'visitors',
|
|
345
|
+
options: { type: 'comparison' as const },
|
|
346
|
+
data: [ { label: 'Mon', value: 40 } ],
|
|
347
|
+
},
|
|
348
|
+
];
|
|
349
|
+
|
|
350
|
+
const { result } = renderHook(
|
|
351
|
+
() => useChartLegendItems( pairedData, { collapseGroups: true, comparisonItem: true } ),
|
|
352
|
+
{ wrapper }
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
expect( result.current.map( item => item.label ) ).toEqual( [
|
|
356
|
+
'Views',
|
|
357
|
+
'Visitors',
|
|
358
|
+
'Comparison period',
|
|
359
|
+
] );
|
|
360
|
+
expect( result.current[ 0 ].color ).not.toBe( result.current[ 1 ].color );
|
|
361
|
+
expect( result.current[ 2 ].color ).toBe( result.current[ 0 ].color );
|
|
362
|
+
} );
|
|
363
|
+
|
|
364
|
+
test( 'adds nothing when no series is a comparison', () => {
|
|
365
|
+
const { result } = renderHook(
|
|
366
|
+
() => useChartLegendItems( [ comparisonData[ 0 ] ], { comparisonItem: true } ),
|
|
367
|
+
{ wrapper }
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
expect( result.current.map( item => item.label ) ).toEqual( [ 'Views' ] );
|
|
371
|
+
} );
|
|
372
|
+
} );
|
|
373
|
+
|
|
250
374
|
describe( 'Label and Color', () => {
|
|
251
375
|
test( 'preserves label and color from data', () => {
|
|
252
376
|
const data: DataPointPercentageCalculated[] = [
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { formatNumber } from '@automattic/number-formatters';
|
|
2
|
+
import { __ } from '@wordpress/i18n';
|
|
2
3
|
import { useMemo } from 'react';
|
|
3
4
|
import {
|
|
4
5
|
useGlobalChartsContext,
|
|
@@ -30,6 +31,12 @@ export interface ChartLegendOptions {
|
|
|
30
31
|
* primary series. Off by default, so every series gets its own item.
|
|
31
32
|
*/
|
|
32
33
|
collapseGroups?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Append a static item explaining the comparison overlay whenever a series has
|
|
36
|
+
* `options.type === 'comparison'`. Skipped when that series already has its own
|
|
37
|
+
* item. Pass a string to replace the default label.
|
|
38
|
+
*/
|
|
39
|
+
comparisonItem?: boolean | string;
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
/**
|
|
@@ -234,6 +241,41 @@ function processSeriesData(
|
|
|
234
241
|
} );
|
|
235
242
|
}
|
|
236
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Builds the static item that names the comparison overlay, styled like its first series.
|
|
246
|
+
* Skipped when a comparison series already has an item of its own, so nothing is listed twice.
|
|
247
|
+
* @param seriesData - The series data to search for a comparison series
|
|
248
|
+
* @param items - The legend items already built from the series
|
|
249
|
+
* @param label - The item label
|
|
250
|
+
* @param getElementStyles - Function to get element styles
|
|
251
|
+
* @param legendShape - The shape type for legend items (string literal or React component)
|
|
252
|
+
* @return The legend item, or null when it would add nothing
|
|
253
|
+
*/
|
|
254
|
+
function buildComparisonLegendItem(
|
|
255
|
+
seriesData: SeriesData[],
|
|
256
|
+
items: BaseLegendItem[],
|
|
257
|
+
label: string,
|
|
258
|
+
getElementStyles: ( params: GetElementStylesParams ) => ElementStyles,
|
|
259
|
+
legendShape?: LegendShape< SeriesData[], number >
|
|
260
|
+
): BaseLegendItem | null {
|
|
261
|
+
const itemLabels = new Set( items.map( item => item.label ) );
|
|
262
|
+
const index = seriesData.findIndex(
|
|
263
|
+
series => series.options?.type === 'comparison' && ! itemLabels.has( series.label )
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
if ( index === -1 ) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const { color, shapeStyles } = getElementStyles( {
|
|
271
|
+
data: seriesData[ index ],
|
|
272
|
+
index,
|
|
273
|
+
legendShape,
|
|
274
|
+
} );
|
|
275
|
+
|
|
276
|
+
return { label, color, shapeStyle: shapeStyles, interactive: false };
|
|
277
|
+
}
|
|
278
|
+
|
|
237
279
|
/**
|
|
238
280
|
* Processes point data into legend items
|
|
239
281
|
* @param pointData - The point data to process
|
|
@@ -296,6 +338,7 @@ export function useChartLegendItems<
|
|
|
296
338
|
withGlyph = false,
|
|
297
339
|
glyphSize = 8,
|
|
298
340
|
collapseGroups = false,
|
|
341
|
+
comparisonItem = false,
|
|
299
342
|
renderGlyph,
|
|
300
343
|
} = options;
|
|
301
344
|
const { getElementStyles } = useGlobalChartsContext();
|
|
@@ -307,8 +350,9 @@ export function useChartLegendItems<
|
|
|
307
350
|
|
|
308
351
|
// Handle SeriesData (multiple series with data points)
|
|
309
352
|
if ( 'data' in data[ 0 ] ) {
|
|
310
|
-
|
|
311
|
-
|
|
353
|
+
const seriesData = data as SeriesData[];
|
|
354
|
+
const items = processSeriesData(
|
|
355
|
+
seriesData,
|
|
312
356
|
getElementStyles,
|
|
313
357
|
showValues,
|
|
314
358
|
withGlyph,
|
|
@@ -317,6 +361,19 @@ export function useChartLegendItems<
|
|
|
317
361
|
renderGlyph,
|
|
318
362
|
legendShape
|
|
319
363
|
);
|
|
364
|
+
const comparison = comparisonItem
|
|
365
|
+
? buildComparisonLegendItem(
|
|
366
|
+
seriesData,
|
|
367
|
+
items,
|
|
368
|
+
typeof comparisonItem === 'string'
|
|
369
|
+
? comparisonItem
|
|
370
|
+
: __( 'Comparison period', 'jetpack-charts' ),
|
|
371
|
+
getElementStyles,
|
|
372
|
+
legendShape
|
|
373
|
+
)
|
|
374
|
+
: null;
|
|
375
|
+
|
|
376
|
+
return comparison ? [ ...items, comparison ] : items;
|
|
320
377
|
}
|
|
321
378
|
|
|
322
379
|
// Handle DataPointDate or DataPointPercentageCalculated (single data points)
|
|
@@ -338,6 +395,7 @@ export function useChartLegendItems<
|
|
|
338
395
|
withGlyph,
|
|
339
396
|
glyphSize,
|
|
340
397
|
collapseGroups,
|
|
398
|
+
comparisonItem,
|
|
341
399
|
renderGlyph,
|
|
342
400
|
legendShape,
|
|
343
401
|
] );
|
|
@@ -16,7 +16,7 @@ import { ChartInstanceContext } from '../../../charts/private/chart-instance-con
|
|
|
16
16
|
import { useTextTruncation } from '../../../hooks';
|
|
17
17
|
import { GlobalChartsContext, useGlobalChartsTheme } from '../../../providers';
|
|
18
18
|
import { useStandaloneScopeClass } from '../../../providers/chart-scope';
|
|
19
|
-
import { valueOrIdentity, valueOrIdentityString
|
|
19
|
+
import { valueOrIdentity, valueOrIdentityString } from '../utils';
|
|
20
20
|
import styles from './base-legend.module.scss';
|
|
21
21
|
import type { BaseLegendItem, BaseLegendProps } from '../types';
|
|
22
22
|
|
|
@@ -123,7 +123,7 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
123
123
|
fill = valueOrIdentityString,
|
|
124
124
|
size = valueOrIdentityString,
|
|
125
125
|
labelFormat = valueOrIdentity,
|
|
126
|
-
labelTransform
|
|
126
|
+
labelTransform,
|
|
127
127
|
itemStyles,
|
|
128
128
|
itemClassName,
|
|
129
129
|
labelStyles,
|
|
@@ -154,11 +154,22 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
154
154
|
const chartInstanceContext = useContext( ChartInstanceContext );
|
|
155
155
|
const standaloneScopeClass = useStandaloneScopeClass();
|
|
156
156
|
|
|
157
|
+
// Keep duplicate labels: a static comparison item can share a metric's name.
|
|
158
|
+
const domain = items.map( item => item.label );
|
|
157
159
|
const legendScale = scaleOrdinal( {
|
|
158
|
-
domain
|
|
160
|
+
domain,
|
|
159
161
|
range: items.map( item => item.color ),
|
|
160
162
|
} );
|
|
161
|
-
const
|
|
163
|
+
const defaultLabelTransform = useCallback< NonNullable< BaseLegendProps[ 'labelTransform' ] > >(
|
|
164
|
+
( { labelFormat: format } ) =>
|
|
165
|
+
( datum, index ) => ( {
|
|
166
|
+
datum,
|
|
167
|
+
index,
|
|
168
|
+
text: String( format( datum, index ) ),
|
|
169
|
+
value: items[ index ].color,
|
|
170
|
+
} ),
|
|
171
|
+
[ items ]
|
|
172
|
+
);
|
|
162
173
|
|
|
163
174
|
const getShapeStyle = useCallback(
|
|
164
175
|
( { index }: { index: number } ) => items[ index ]?.shapeStyle,
|
|
@@ -178,8 +189,7 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
178
189
|
[ interactive, chartId, context ]
|
|
179
190
|
);
|
|
180
191
|
|
|
181
|
-
//
|
|
182
|
-
// programmatically must read as hidden even when the legend cannot be clicked.
|
|
192
|
+
// Disabling interaction for the whole legend still preserves its series' visibility state.
|
|
183
193
|
const isSeriesVisible = useCallback(
|
|
184
194
|
( seriesLabel: string ) => {
|
|
185
195
|
if ( chartInstanceContext?.isSeriesVisible ) {
|
|
@@ -195,18 +205,18 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
195
205
|
|
|
196
206
|
// Create event handlers to avoid inline arrow functions
|
|
197
207
|
const createClickHandler = useCallback(
|
|
198
|
-
( seriesLabels: string[] ) => {
|
|
199
|
-
if ( !
|
|
208
|
+
( seriesLabels: string[], itemInteractive: boolean ) => {
|
|
209
|
+
if ( ! itemInteractive ) {
|
|
200
210
|
return undefined;
|
|
201
211
|
}
|
|
202
212
|
return () => handleLegendClick( seriesLabels );
|
|
203
213
|
},
|
|
204
|
-
[
|
|
214
|
+
[ handleLegendClick ]
|
|
205
215
|
);
|
|
206
216
|
|
|
207
217
|
const createKeyDownHandler = useCallback(
|
|
208
|
-
( seriesLabels: string[] ) => {
|
|
209
|
-
if ( !
|
|
218
|
+
( seriesLabels: string[], itemInteractive: boolean ) => {
|
|
219
|
+
if ( ! itemInteractive ) {
|
|
210
220
|
return undefined;
|
|
211
221
|
}
|
|
212
222
|
return ( event: KeyboardEvent ) => {
|
|
@@ -216,18 +226,20 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
216
226
|
}
|
|
217
227
|
};
|
|
218
228
|
},
|
|
219
|
-
[
|
|
229
|
+
[ handleLegendClick ]
|
|
220
230
|
);
|
|
221
231
|
|
|
222
232
|
const flexAlignment = ALIGNMENT_TO_FLEX[ alignment ] ?? 'center';
|
|
233
|
+
const staticItemRole = interactive ? undefined : 'listitem';
|
|
223
234
|
|
|
224
235
|
return render ? (
|
|
225
236
|
render( items )
|
|
226
237
|
) : (
|
|
227
238
|
<LegendOrdinal
|
|
228
239
|
scale={ legendScale }
|
|
240
|
+
domain={ domain }
|
|
229
241
|
labelFormat={ labelFormat }
|
|
230
|
-
labelTransform={ labelTransform }
|
|
242
|
+
labelTransform={ labelTransform ?? defaultLabelTransform }
|
|
231
243
|
>
|
|
232
244
|
{ labels => (
|
|
233
245
|
<Stack
|
|
@@ -248,16 +260,18 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
248
260
|
const seriesLabels = matchedItem?.seriesLabels?.length
|
|
249
261
|
? matchedItem.seriesLabels
|
|
250
262
|
: [ label.text ];
|
|
251
|
-
const visible =
|
|
252
|
-
|
|
253
|
-
const
|
|
263
|
+
const visible =
|
|
264
|
+
matchedItem?.interactive === false || isSeriesVisible( seriesLabels[ 0 ] );
|
|
265
|
+
const itemInteractive = interactive && matchedItem?.interactive !== false;
|
|
266
|
+
const handleClick = createClickHandler( seriesLabels, itemInteractive );
|
|
267
|
+
const handleKeyDown = createKeyDownHandler( seriesLabels, itemInteractive );
|
|
254
268
|
|
|
255
269
|
return (
|
|
256
270
|
<LegendItem
|
|
257
271
|
className={ clsx(
|
|
258
272
|
'visx-legend-item',
|
|
259
273
|
styles[ 'legend-item' ],
|
|
260
|
-
|
|
274
|
+
itemInteractive && styles[ 'legend-item--interactive' ],
|
|
261
275
|
! visible && styles[ 'legend-item--inactive' ],
|
|
262
276
|
itemClassName
|
|
263
277
|
) }
|
|
@@ -271,14 +285,15 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
271
285
|
}
|
|
272
286
|
onClick={ handleClick }
|
|
273
287
|
onKeyDown={ handleKeyDown }
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
288
|
+
// A static item in an interactive legend has no list to belong to.
|
|
289
|
+
role={ itemInteractive ? 'button' : staticItemRole }
|
|
290
|
+
tabIndex={ itemInteractive ? 0 : undefined }
|
|
291
|
+
aria-pressed={ itemInteractive ? visible : undefined }
|
|
277
292
|
aria-label={ getLegendItemAriaLabel(
|
|
278
293
|
label.text,
|
|
279
294
|
matchedItem?.value,
|
|
280
295
|
visible,
|
|
281
|
-
|
|
296
|
+
itemInteractive
|
|
282
297
|
) }
|
|
283
298
|
>
|
|
284
299
|
{ items[ i ]?.renderGlyph ? (
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
useChartRegistration,
|
|
10
10
|
useGlobalChartsContext,
|
|
11
11
|
} from '../../../providers';
|
|
12
|
+
import { useChartLegendItems } from '../hooks/use-chart-legend-items';
|
|
12
13
|
import { Legend } from '../legend';
|
|
13
14
|
import { BaseLegend } from '../private/base-legend';
|
|
14
15
|
import type { ChartType } from '../../../types';
|
|
@@ -49,6 +50,26 @@ describe( 'BaseLegend', () => {
|
|
|
49
50
|
expect( markers[ 1 ] ).toHaveAttribute( 'fill', '#00ff00' );
|
|
50
51
|
} );
|
|
51
52
|
|
|
53
|
+
test( 'preserves each duplicate label, color, and following item', () => {
|
|
54
|
+
render(
|
|
55
|
+
<BaseLegend
|
|
56
|
+
items={ [
|
|
57
|
+
{ label: 'Views', color: '#ff0000' },
|
|
58
|
+
{ label: 'Views', color: '#00ff00', interactive: false },
|
|
59
|
+
{ label: 'Visitors', color: '#0000ff' },
|
|
60
|
+
] }
|
|
61
|
+
shape={ TestShape }
|
|
62
|
+
labelFormat={ label => `Metric: ${ label }` }
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
expect( screen.getAllByText( 'Metric: Views' ) ).toHaveLength( 2 );
|
|
66
|
+
expect( screen.getByText( 'Metric: Visitors' ) ).toBeInTheDocument();
|
|
67
|
+
const markers = screen.getAllByTestId( 'legend-marker' );
|
|
68
|
+
expect( markers[ 0 ] ).toHaveAttribute( 'fill', '#ff0000' );
|
|
69
|
+
expect( markers[ 1 ] ).toHaveAttribute( 'fill', '#00ff00' );
|
|
70
|
+
expect( markers[ 2 ] ).toHaveAttribute( 'fill', '#0000ff' );
|
|
71
|
+
} );
|
|
72
|
+
|
|
52
73
|
test( 'handles empty items array', () => {
|
|
53
74
|
render( <BaseLegend items={ [] } orientation="horizontal" /> );
|
|
54
75
|
const legendItems = screen.queryAllByRole( 'listitem' );
|
|
@@ -754,6 +775,69 @@ describe( 'BaseLegend', () => {
|
|
|
754
775
|
expect( screen.getByTestId( 'views-prev' ) ).toHaveTextContent( 'true' );
|
|
755
776
|
} );
|
|
756
777
|
|
|
778
|
+
it( 'keeps an item with interactive: false static inside an interactive legend', async () => {
|
|
779
|
+
const user = userEvent.setup();
|
|
780
|
+
const items = [
|
|
781
|
+
{ label: 'Views', color: '#0000ff' },
|
|
782
|
+
{ label: 'Comparison period', color: '#0000ff', interactive: false },
|
|
783
|
+
];
|
|
784
|
+
|
|
785
|
+
render(
|
|
786
|
+
<GlobalChartsProvider>
|
|
787
|
+
<BaseLegend items={ items } interactive={ true } chartId="test-chart" />
|
|
788
|
+
</GlobalChartsProvider>
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
expect( screen.getAllByRole( 'button' ) ).toHaveLength( 1 );
|
|
792
|
+
const staticItem = screen.getAllByTestId( 'legend-item' )[ 1 ];
|
|
793
|
+
expect( staticItem ).toHaveTextContent( 'Comparison period' );
|
|
794
|
+
expect( staticItem ).not.toHaveAttribute( 'role' );
|
|
795
|
+
expect( staticItem ).not.toHaveAttribute( 'tabindex' );
|
|
796
|
+
expect( staticItem ).not.toHaveAttribute( 'aria-pressed' );
|
|
797
|
+
|
|
798
|
+
await user.click( staticItem );
|
|
799
|
+
|
|
800
|
+
expect( screen.getByRole( 'button' ) ).toHaveAttribute( 'aria-pressed', 'true' );
|
|
801
|
+
expect( staticItem ).not.toHaveClass( 'legend-item--inactive' );
|
|
802
|
+
} );
|
|
803
|
+
|
|
804
|
+
it( 'keeps a comparison item distinct when its label collides', async () => {
|
|
805
|
+
const user = userEvent.setup();
|
|
806
|
+
const Example = () => {
|
|
807
|
+
const items = useChartLegendItems(
|
|
808
|
+
[
|
|
809
|
+
{
|
|
810
|
+
label: 'Comparison period',
|
|
811
|
+
group: 'metric',
|
|
812
|
+
data: [ { label: 'Mon', value: 10 } ],
|
|
813
|
+
},
|
|
814
|
+
{
|
|
815
|
+
label: 'Previous metric',
|
|
816
|
+
group: 'metric',
|
|
817
|
+
options: { type: 'comparison' },
|
|
818
|
+
data: [ { label: 'Mon', value: 5 } ],
|
|
819
|
+
},
|
|
820
|
+
],
|
|
821
|
+
{ collapseGroups: true, comparisonItem: true },
|
|
822
|
+
'line'
|
|
823
|
+
);
|
|
824
|
+
return <BaseLegend items={ items } interactive chartId="test-chart" />;
|
|
825
|
+
};
|
|
826
|
+
render(
|
|
827
|
+
<GlobalChartsProvider>
|
|
828
|
+
<Example />
|
|
829
|
+
</GlobalChartsProvider>
|
|
830
|
+
);
|
|
831
|
+
expect( screen.getAllByTestId( 'legend-item' ) ).toHaveLength( 2 );
|
|
832
|
+
const staticItem = screen.getAllByTestId( 'legend-item' )[ 1 ];
|
|
833
|
+
expect( staticItem ).not.toHaveAttribute( 'role' );
|
|
834
|
+
await user.click( screen.getByRole( 'button' ) );
|
|
835
|
+
expect( screen.getByRole( 'button' ) ).toHaveAttribute( 'aria-pressed', 'false' );
|
|
836
|
+
expect( staticItem ).not.toHaveClass( 'legend-item--inactive' );
|
|
837
|
+
await user.click( staticItem );
|
|
838
|
+
expect( screen.getByRole( 'button' ) ).toHaveAttribute( 'aria-pressed', 'false' );
|
|
839
|
+
} );
|
|
840
|
+
|
|
757
841
|
it( 'falls back to the item label when seriesLabels is an empty array', async () => {
|
|
758
842
|
const user = userEvent.setup();
|
|
759
843
|
|
|
@@ -172,7 +172,7 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
|
|
|
172
172
|
tooltipContext?.showTooltip( {
|
|
173
173
|
datum: tooltipItem.datum,
|
|
174
174
|
key: tooltipItem.seriesLabel,
|
|
175
|
-
index: tooltipItem.
|
|
175
|
+
index: tooltipItem.dataPointIndex,
|
|
176
176
|
} );
|
|
177
177
|
}
|
|
178
178
|
}
|