@automattic/charts 1.8.0 → 1.8.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
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.2.4",
66
+ "@automattic/number-formatters": "^1.2.5",
67
67
  "@babel/runtime": "7.29.7",
68
68
  "@react-spring/web": "10.1.1",
69
69
  "@visx/annotation": "^4.0.0",
@@ -1,7 +1,7 @@
1
1
  import { formatNumber } from '@automattic/number-formatters';
2
2
  import { PatternLines, PatternCircles, PatternWaves, PatternHexagons } from '@visx/pattern';
3
3
  import { Axis, BarSeries, BarGroup, Grid, XYChart } from '@visx/xychart';
4
- import { __ } from '@wordpress/i18n';
4
+ import { __, sprintf } from '@wordpress/i18n';
5
5
  import clsx from 'clsx';
6
6
  import { useCallback, useContext, useState, useRef, useMemo } from 'react';
7
7
  import { Legend, useChartLegendItems } from '../../components/legend';
@@ -83,6 +83,19 @@ const validateData = ( data: SeriesData[] ) => {
83
83
 
84
84
  const getPatternId = ( chartId: string, index: number ) => `bar-pattern-${ chartId }-${ index }`;
85
85
 
86
+ // A "label: value" tooltip row. The join is a translated format string so the
87
+ // separator (a colon + space here) can be adapted per locale.
88
+ const renderTooltipRow = ( label: string | undefined, value: string ) => (
89
+ <div className={ styles[ 'bar-chart__tooltip-row' ] }>
90
+ { sprintf(
91
+ /* translators: 1: data series, period, or category label. 2: its formatted value. */
92
+ __( '%1$s: %2$s', 'jetpack-charts' ),
93
+ label,
94
+ value
95
+ ) }
96
+ </div>
97
+ );
98
+
86
99
  const BarChartInternal: FC< BarChartProps > = ( {
87
100
  data,
88
101
  chartId: providedChartId,
@@ -191,6 +204,14 @@ const BarChartInternal: FC< BarChartProps > = ( {
191
204
  [ primaryEntries ]
192
205
  );
193
206
 
207
+ // The keyboard-navigation index space and the highlight CSS both stride over primary
208
+ // bars only; the accessible tooltip must use the same list, or its datum diverges from
209
+ // the highlighted bar once a comparison series shifts the indices.
210
+ const primarySeries = useMemo(
211
+ () => primaryEntries.map( ( { series } ) => series ),
212
+ [ primaryEntries ]
213
+ );
214
+
194
215
  const comparisonEntries = useMemo( () => {
195
216
  const primaryByGroup = new Map< string | undefined, { label: string; index: number } >(
196
217
  primaryEntries.map( ( { series, index } ) => [
@@ -309,20 +330,11 @@ const BarChartInternal: FC< BarChartProps > = ( {
309
330
  return (
310
331
  <div className={ styles[ 'bar-chart__tooltip' ] }>
311
332
  <div className={ styles[ 'bar-chart__tooltip-header' ] }>{ categoryLabel }</div>
312
- <div className={ styles[ 'bar-chart__tooltip-row' ] }>
313
- <span className={ styles[ 'bar-chart__tooltip-label' ] }>{ primaryKey }:</span>
314
- <span className={ styles[ 'bar-chart__tooltip-value' ] }>
315
- { formatNumber( nearestDatum.value as number ) }
316
- </span>
317
- </div>
318
- <div className={ styles[ 'bar-chart__tooltip-row' ] }>
319
- <span className={ styles[ 'bar-chart__tooltip-label' ] }>
320
- { comparisonEntry.series.label }:
321
- </span>
322
- <span className={ styles[ 'bar-chart__tooltip-value' ] }>
323
- { formatNumber( comparisonDatum.value as number ) }
324
- </span>
325
- </div>
333
+ { renderTooltipRow( primaryKey, formatNumber( nearestDatum.value as number ) ) }
334
+ { renderTooltipRow(
335
+ comparisonEntry.series.label,
336
+ formatNumber( comparisonDatum.value as number )
337
+ ) }
326
338
  </div>
327
339
  );
328
340
  }
@@ -330,12 +342,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
330
342
  return (
331
343
  <div className={ styles[ 'bar-chart__tooltip' ] }>
332
344
  <div className={ styles[ 'bar-chart__tooltip-header' ] }>{ primaryKey }</div>
333
- <div className={ styles[ 'bar-chart__tooltip-row' ] }>
334
- <span className={ styles[ 'bar-chart__tooltip-label' ] }>{ categoryLabel }:</span>
335
- <span className={ styles[ 'bar-chart__tooltip-value' ] }>
336
- { formatNumber( nearestDatum.value as number ) }
337
- </span>
338
- </div>
345
+ { renderTooltipRow( categoryLabel, formatNumber( nearestDatum.value as number ) ) }
339
346
  </div>
340
347
  );
341
348
  },
@@ -622,7 +629,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
622
629
  keyboardFocusedClassName={
623
630
  styles[ 'bar-chart__tooltip--keyboard-focused' ]
624
631
  }
625
- series={ data }
632
+ series={ primarySeries }
626
633
  mode="individual"
627
634
  />
628
635
  ) }
@@ -146,6 +146,7 @@ export const ComparisonBars: FC< {
146
146
  <g
147
147
  className="bar-chart__comparison-bars"
148
148
  pointerEvents="none"
149
+ aria-hidden="true"
149
150
  data-testid="bar-chart-comparison-bars"
150
151
  >
151
152
  { rects }
@@ -123,8 +123,11 @@ export function useBarChartOptions(
123
123
  } );
124
124
  } );
125
125
  if ( allValues.length > 0 ) {
126
+ // Keep zero in the domain so bar length stays proportional to value — a
127
+ // non-zero baseline would exaggerate differences between periods. Math.max
128
+ // keeps zero on the far side too, so charts with negative values still span 0.
126
129
  valueScaleDomainOverride = {
127
- domain: [ Math.min( ...allValues ), Math.max( ...allValues ) ],
130
+ domain: [ Math.min( 0, ...allValues ), Math.max( 0, ...allValues ) ],
128
131
  };
129
132
  }
130
133
  }
@@ -322,6 +322,8 @@ describe( 'BarChart', () => {
322
322
  await user.keyboard( '{ArrowRight}' );
323
323
  expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
324
324
  expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'Series A' );
325
+ // The category/value row joins with a space after the colon.
326
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'Jan 1: 10' );
325
327
  expect( screen.queryByTestId( 'chart-tooltip-1' ) ).not.toBeInTheDocument();
326
328
 
327
329
  // Second tab should focus on the second tooltip.
@@ -409,11 +411,50 @@ describe( 'BarChart', () => {
409
411
 
410
412
  await user.keyboard( '{ArrowRight}' );
411
413
  const tooltip = screen.getByTestId( 'chart-tooltip-0' );
412
- // Both the current and previous period values are shown.
414
+ // Both periods are shown, each as "label: value" with a space after the colon.
415
+ expect( tooltip ).toHaveTextContent( 'This period: 10' );
416
+ expect( tooltip ).toHaveTextContent( 'Previous period: 15' );
417
+ } );
418
+
419
+ test( 'keyboard navigation past the first slot stays on the primary bar, not the comparison series', async () => {
420
+ const user = userEvent.setup();
421
+ renderWithTheme( {
422
+ withTooltips: true,
423
+ data: [
424
+ {
425
+ label: 'This period',
426
+ group: 'views',
427
+ data: [
428
+ { label: 'Mon', value: 10 },
429
+ { label: 'Tue', value: 20 },
430
+ ],
431
+ },
432
+ {
433
+ label: 'Previous period',
434
+ group: 'views',
435
+ options: { type: 'comparison' as const },
436
+ data: [
437
+ { label: 'Mon', value: 15 },
438
+ { label: 'Tue', value: 25 },
439
+ ],
440
+ },
441
+ ],
442
+ } );
443
+
444
+ const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
445
+ chart.focus();
446
+
447
+ // Slot 0 is the first primary bar (Mon); slot 1 must be the SECOND primary
448
+ // bar (Tue) — not the comparison series' first bar. The keyboard index space
449
+ // counts only primary bars, so the tooltip must too.
450
+ await user.keyboard( '{ArrowRight}' );
451
+ await user.keyboard( '{ArrowRight}' );
452
+ const tooltip = screen.getByTestId( 'chart-tooltip-1' );
453
+ expect( tooltip ).toHaveTextContent( 'Tue' );
413
454
  expect( tooltip ).toHaveTextContent( 'This period' );
414
- expect( tooltip ).toHaveTextContent( '10' );
455
+ expect( tooltip ).toHaveTextContent( '20' );
415
456
  expect( tooltip ).toHaveTextContent( 'Previous period' );
416
- expect( tooltip ).toHaveTextContent( '15' );
457
+ expect( tooltip ).toHaveTextContent( '25' );
417
458
  } );
418
459
  } );
419
460
 
@@ -860,6 +901,57 @@ describe( 'BarChart', () => {
860
901
  expect( bars.length ).toBeGreaterThanOrEqual( 2 );
861
902
  } );
862
903
 
904
+ it( 'hides the comparison shadow group from assistive technology', () => {
905
+ const data = [
906
+ {
907
+ label: 'This year',
908
+ group: 'views',
909
+ data: [ { label: 'Jan', value: 100 } ],
910
+ },
911
+ {
912
+ label: 'Last year',
913
+ group: 'views',
914
+ options: { type: 'comparison' as const },
915
+ data: [ { label: 'Jan', value: 80 } ],
916
+ },
917
+ ];
918
+ render( <BarChart data={ data } width={ 400 } height={ 300 } /> );
919
+
920
+ // The shadow is decorative: no keyboard/hover target and its value is surfaced
921
+ // through the tooltip, so it must not be announced as a separate element.
922
+ expect( screen.getByTestId( 'bar-chart-comparison-bars' ) ).toHaveAttribute(
923
+ 'aria-hidden',
924
+ 'true'
925
+ );
926
+ } );
927
+
928
+ it( 'renders comparison shadows in horizontal orientation', () => {
929
+ const data = [
930
+ {
931
+ label: 'This year',
932
+ group: 'views',
933
+ data: [
934
+ { label: 'Jan', value: 100 },
935
+ { label: 'Feb', value: 120 },
936
+ ],
937
+ },
938
+ {
939
+ label: 'Last year',
940
+ group: 'views',
941
+ options: { type: 'comparison' as const },
942
+ data: [
943
+ { label: 'Jan', value: 80 },
944
+ { label: 'Feb', value: 140 },
945
+ ],
946
+ },
947
+ ];
948
+ render( <BarChart data={ data } orientation="horizontal" width={ 400 } height={ 300 } /> );
949
+
950
+ const shadows = screen.getAllByTestId( /^bar-chart-comparison-\d+-\d+$/ );
951
+ expect( shadows ).toHaveLength( 2 );
952
+ expect( shadows[ 0 ] ).toHaveAttribute( 'opacity', '0.5' );
953
+ } );
954
+
863
955
  it( 'expands value-axis domain to include comparison values exceeding the primary max', () => {
864
956
  // Comparison value 150 exceeds primary max 100.
865
957
  // Without domain expansion the value-axis scale config has no explicit domain,
@@ -886,6 +978,52 @@ describe( 'BarChart', () => {
886
978
  expect( ( yScale.domain as number[] )[ 1 ] ).toBeGreaterThanOrEqual( 150 );
887
979
  } );
888
980
 
981
+ it( 'keeps the value-axis baseline at zero so comparison bars encode magnitude truthfully', () => {
982
+ // All values are well above zero; the domain must still start at 0 so a bar's
983
+ // length stays proportional to its value (a non-zero baseline exaggerates differences).
984
+ const data = [
985
+ {
986
+ label: 'This period',
987
+ group: 'views',
988
+ data: [ { label: 'Mon', value: 420 } ],
989
+ },
990
+ {
991
+ label: 'Previous period',
992
+ group: 'views',
993
+ options: { type: 'comparison' as const },
994
+ data: [ { label: 'Mon', value: 510 } ],
995
+ },
996
+ ];
997
+
998
+ const { result } = renderHook( () => useBarChartOptions( data, false, {} ) );
999
+ const yScale = result.current.yScale as { domain?: number[] };
1000
+ expect( yScale.domain ).toBeDefined();
1001
+ expect( ( yScale.domain as number[] )[ 0 ] ).toBe( 0 );
1002
+ } );
1003
+
1004
+ it( 'zero-bases the value-axis domain in horizontal comparison charts', () => {
1005
+ const data = [
1006
+ {
1007
+ label: 'This period',
1008
+ group: 'views',
1009
+ data: [ { label: 'Mon', value: 420 } ],
1010
+ },
1011
+ {
1012
+ label: 'Previous period',
1013
+ group: 'views',
1014
+ options: { type: 'comparison' as const },
1015
+ data: [ { label: 'Mon', value: 510 } ],
1016
+ },
1017
+ ];
1018
+
1019
+ // In horizontal charts the value axis is x.
1020
+ const { result } = renderHook( () => useBarChartOptions( data, true, {} ) );
1021
+ const xScale = result.current.xScale as { domain?: number[] };
1022
+ expect( xScale.domain ).toBeDefined();
1023
+ expect( ( xScale.domain as number[] )[ 0 ] ).toBe( 0 );
1024
+ expect( ( xScale.domain as number[] )[ 1 ] ).toBeGreaterThanOrEqual( 510 );
1025
+ } );
1026
+
889
1027
  it( 'counts only primary series for keyboard navigation when a comparison series is present', async () => {
890
1028
  const user = userEvent.setup();
891
1029
  // 2 primary + 1 comparison. totalPoints should be 2*2=4, not 3*2=6.