@automattic/charts 4.1.1 → 4.3.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.
Files changed (97) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/TOKENS.md +1 -1
  3. package/dist/index.cjs +1335 -348
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +30 -1
  6. package/dist/index.d.cts +129 -8
  7. package/dist/index.d.ts +129 -8
  8. package/dist/index.js +1336 -351
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -8
  11. package/src/charts/area-chart/area-chart.tsx +42 -10
  12. package/src/charts/area-chart/private/overlays.tsx +1 -2
  13. package/src/charts/area-chart/private/validate-data.ts +2 -3
  14. package/src/charts/area-chart/test/area-chart.test.tsx +72 -1
  15. package/src/charts/bar-chart/bar-chart.tsx +137 -70
  16. package/src/charts/bar-chart/index.ts +1 -1
  17. package/src/charts/bar-chart/private/band-highlight.tsx +87 -0
  18. package/src/charts/bar-chart/private/band-scale.ts +13 -0
  19. package/src/charts/bar-chart/private/band-tooltip.tsx +138 -0
  20. package/src/charts/bar-chart/private/comparison-bars.tsx +5 -10
  21. package/src/charts/bar-chart/private/index.ts +3 -0
  22. package/src/charts/bar-chart/private/test/band-highlight.test.tsx +135 -0
  23. package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
  24. package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
  25. package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
  26. package/src/charts/bar-chart/private/use-bar-chart-options.ts +13 -40
  27. package/src/charts/bar-chart/private/value-domain.ts +90 -0
  28. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
  29. package/src/charts/bar-chart/test/bar-chart.test.tsx +1058 -19
  30. package/src/charts/bar-chart/types.ts +39 -0
  31. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -3
  32. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
  33. package/src/charts/conversion-funnel-chart/types.ts +4 -2
  34. package/src/charts/geo-chart/types.ts +4 -2
  35. package/src/charts/heatmap-chart/build-calendar-data.ts +5 -11
  36. package/src/charts/heatmap-chart/build-month-calendar-data.ts +124 -0
  37. package/src/charts/heatmap-chart/heatmap-chart.module.scss +32 -0
  38. package/src/charts/heatmap-chart/heatmap-chart.tsx +191 -114
  39. package/src/charts/heatmap-chart/index.ts +6 -0
  40. package/src/charts/heatmap-chart/private/civil-day.ts +26 -0
  41. package/src/charts/heatmap-chart/private/column-groups.ts +71 -0
  42. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +19 -3
  43. package/src/charts/heatmap-chart/private/index.ts +2 -0
  44. package/src/charts/heatmap-chart/private/keyboard-navigation.ts +201 -0
  45. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +36 -0
  46. package/src/charts/heatmap-chart/test/build-month-calendar-data.test.ts +194 -0
  47. package/src/charts/heatmap-chart/test/column-groups.test.ts +83 -0
  48. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +428 -2
  49. package/src/charts/heatmap-chart/test/keyboard-navigation.test.ts +25 -0
  50. package/src/charts/heatmap-chart/test/use-month-calendar-heatmap-data.test.tsx +90 -0
  51. package/src/charts/heatmap-chart/test/zoned-month-calendar-data.test.ts +23 -0
  52. package/src/charts/heatmap-chart/types.ts +59 -3
  53. package/src/charts/heatmap-chart/use-month-calendar-heatmap-data.ts +34 -0
  54. package/src/charts/leaderboard-chart/types.ts +13 -14
  55. package/src/charts/line-chart/line-chart.tsx +149 -36
  56. package/src/charts/line-chart/private/index.ts +1 -0
  57. package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
  58. package/src/charts/line-chart/private/line-chart-glyph.tsx +17 -3
  59. package/src/charts/line-chart/private/nearest-pointer-events.tsx +155 -0
  60. package/src/charts/line-chart/test/line-chart.test.tsx +604 -9
  61. package/src/charts/line-chart/types.ts +4 -3
  62. package/src/charts/private/readings.ts +34 -0
  63. package/src/charts/private/test/readings.test.ts +29 -0
  64. package/src/charts/private/test/whole-number-ticks.test.tsx +57 -0
  65. package/src/charts/private/time-axis-options.ts +1 -1
  66. package/src/charts/private/whole-number-ticks.tsx +79 -0
  67. package/src/charts/private/with-responsive/with-responsive.tsx +2 -2
  68. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +1 -3
  69. package/src/charts/private/x-zoom/x-zoom.tsx +1 -2
  70. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +124 -0
  71. package/src/components/legend/hooks/use-chart-legend-items.ts +60 -2
  72. package/src/components/legend/private/base-legend.tsx +37 -22
  73. package/src/components/legend/test/legend.test.tsx +84 -0
  74. package/src/components/legend/types.ts +2 -0
  75. package/src/components/legend/utils/index.ts +0 -1
  76. package/src/components/tooltip/accessible-tooltip.tsx +128 -41
  77. package/src/components/tooltip/base-tooltip.module.scss +6 -1
  78. package/src/components/tooltip/private/bounded-tooltip.tsx +12 -6
  79. package/src/components/tooltip/test/accessible-tooltip.test.tsx +128 -0
  80. package/src/components/tooltip/test/bounded-tooltip.test.tsx +17 -0
  81. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +7 -0
  82. package/src/components/tooltip/xy-chart-tooltip.tsx +9 -10
  83. package/src/hooks/test/use-chart-margin.test.tsx +35 -0
  84. package/src/hooks/test/use-element-size.test.tsx +13 -13
  85. package/src/hooks/use-chart-margin.tsx +7 -2
  86. package/src/hooks/use-xychart-theme.ts +1 -1
  87. package/src/index.ts +7 -1
  88. package/src/providers/chart-context/global-charts-provider.tsx +1 -1
  89. package/src/providers/chart-context/test/chart-context.test.tsx +4 -4
  90. package/src/providers/chart-context/themes.ts +1 -0
  91. package/src/styles/test/chart-scope.test.ts +1 -1
  92. package/src/types.ts +14 -13
  93. package/src/utils/format-metric-value.ts +4 -4
  94. package/src/utils/get-edge-tick-widths.ts +1 -1
  95. package/src/utils/get-styles.ts +1 -1
  96. package/src/visx/types.ts +7 -10
  97. package/src/components/legend/utils/label-transform-factory.ts +0 -24
@@ -0,0 +1,34 @@
1
+ import { formatNumber } from '@automattic/number-formatters';
2
+ import { __ } from '@wordpress/i18n';
3
+
4
+ /**
5
+ * Whether a value is a reading a scale can place.
6
+ *
7
+ * @param value - The point's value.
8
+ * @return True for a finite number.
9
+ */
10
+ export const isReading = ( value: unknown ): value is number =>
11
+ typeof value === 'number' && Number.isFinite( value );
12
+
13
+ /**
14
+ * Whether a data point's value cannot be drawn.
15
+ *
16
+ * @param value - The point's value.
17
+ * @param options - Validation options.
18
+ * @param options.allowMissing - Whether `null` is a bucket with no reading rather than a fault.
19
+ * @return True when the value is invalid.
20
+ */
21
+ export const isInvalidReading = (
22
+ value: number | null | undefined,
23
+ { allowMissing }: { allowMissing: boolean }
24
+ ) => ( value === null ? ! allowMissing : value === undefined || isNaN( value ) );
25
+
26
+ /**
27
+ * Formats a reading for a tooltip.
28
+ *
29
+ * @param value - The reading, or null when the bucket has none.
30
+ * @return The formatted value.
31
+ */
32
+ export const formatReading = ( value: number | null | undefined ) =>
33
+ // formatNumber( null ) is "0", which would claim a reading of zero for a bucket that has none.
34
+ value == null ? __( 'No data', 'jetpack-charts' ) : formatNumber( value );
@@ -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
+ } );
@@ -83,7 +83,7 @@ export const buildTimeAxisOptions = ( {
83
83
  effectiveDomain,
84
84
  ownFormatter,
85
85
  callerNumTicks ?? getMaxTicksForWidth( width )
86
- );
86
+ );
87
87
 
88
88
  // An empty selection means the domain holds no point to tick, not that the
89
89
  // axis wants no ticks: visx reads `[]` as the latter and labels nothing.
@@ -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
+ };
@@ -94,7 +94,7 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o
94
94
  // Cap the available width at maxWidth unless an explicit width pins it. Before
95
95
  // measurement resolves, fall back to the explicit width.
96
96
  const cap = width === undefined ? maxWidth : Infinity;
97
- const availableWidth = parentWidth > 0 ? Math.min( parentWidth, cap ) : width ?? 0;
97
+ const availableWidth = parentWidth > 0 ? Math.min( parentWidth, cap ) : ( width ?? 0 );
98
98
 
99
99
  let boxWidth = availableWidth;
100
100
  let boxHeight: number;
@@ -107,7 +107,7 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o
107
107
  boxHeight = derivedHeight;
108
108
  }
109
109
  } else {
110
- boxHeight = parentHeight > 0 ? parentHeight : height ?? 0;
110
+ boxHeight = parentHeight > 0 ? parentHeight : ( height ?? 0 );
111
111
  }
112
112
 
113
113
  // Decide containment from the wrapper's real height, measured after layout.
@@ -130,9 +130,7 @@ describe( 'useXZoom', () => {
130
130
  } );
131
131
 
132
132
  test( 'leaves domain null when the X scale has no invert function', () => {
133
- const chartRef = makeChartRef( {
134
- /* no invert */
135
- } );
133
+ const chartRef = makeChartRef( { /* no invert */ } );
136
134
  const { result } = renderHook( () => useXZoom< number >( { enabled: true, chartRef } ) );
137
135
 
138
136
  act( () => result.current.handlers.onPointerDown( makeParams( 100 ) ) );
@@ -79,8 +79,7 @@ export function useXZoom< T extends Date | number = Date >( {
79
79
  const hi = Math.max( finalDrag.a, finalDrag.b );
80
80
  if ( hi - lo < MIN_DRAG_PIXELS ) return;
81
81
  const xScale = chartRef.current?.getScales()?.xScale as
82
- | ( AxisScale & { invert?: ( v: number ) => T } )
83
- | undefined;
82
+ ( AxisScale & { invert?: ( v: number ) => T } ) | undefined;
84
83
  if ( ! xScale || typeof xScale.invert !== 'function' ) return;
85
84
  setDomain( [ xScale.invert( lo ), xScale.invert( hi ) ] );
86
85
  },
@@ -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
- return processSeriesData(
311
- data as SeriesData[],
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, labelTransformFactory } from '../utils';
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
 
@@ -75,7 +75,7 @@ const getLegendItemAriaLabel = (
75
75
  _x( '%1$s, %2$s', 'legend item label and value', 'jetpack-charts' ),
76
76
  text,
77
77
  String( value )
78
- )
78
+ )
79
79
  : text;
80
80
 
81
81
  if ( interactive ) {
@@ -123,7 +123,7 @@ export const BaseLegend: ForwardRefExoticComponent<
123
123
  fill = valueOrIdentityString,
124
124
  size = valueOrIdentityString,
125
125
  labelFormat = valueOrIdentity,
126
- labelTransform = labelTransformFactory,
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: items.map( item => item.label ),
160
+ domain,
159
161
  range: items.map( item => item.color ),
160
162
  } );
161
- const domain = legendScale.domain();
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
- // Visibility is display state, not interaction state: a series hidden
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 ( ! interactive ) {
208
+ ( seriesLabels: string[], itemInteractive: boolean ) => {
209
+ if ( ! itemInteractive ) {
200
210
  return undefined;
201
211
  }
202
212
  return () => handleLegendClick( seriesLabels );
203
213
  },
204
- [ interactive, handleLegendClick ]
214
+ [ handleLegendClick ]
205
215
  );
206
216
 
207
217
  const createKeyDownHandler = useCallback(
208
- ( seriesLabels: string[] ) => {
209
- if ( ! interactive ) {
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
- [ interactive, handleLegendClick ]
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 = isSeriesVisible( seriesLabels[ 0 ] );
252
- const handleClick = createClickHandler( seriesLabels );
253
- const handleKeyDown = createKeyDownHandler( seriesLabels );
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
- interactive && styles[ 'legend-item--interactive' ],
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
- role={ interactive ? 'button' : 'listitem' }
275
- tabIndex={ interactive ? 0 : undefined }
276
- aria-pressed={ interactive ? visible : undefined }
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
- interactive
296
+ itemInteractive
282
297
  ) }
283
298
  >
284
299
  { items[ i ]?.renderGlyph ? (