@automattic/charts 3.1.1 → 4.0.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 (116) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/TOKENS.md +80 -46
  3. package/dist/index.cjs +1224 -662
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +72 -23
  6. package/dist/index.d.cts +230 -162
  7. package/dist/index.d.ts +230 -162
  8. package/dist/index.js +1224 -663
  9. package/dist/index.js.map +1 -1
  10. package/package.json +10 -10
  11. package/src/charts/area-chart/area-chart.tsx +39 -25
  12. package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
  13. package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
  14. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
  15. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
  16. package/src/charts/area-chart/types.ts +4 -7
  17. package/src/charts/bar-chart/bar-chart.tsx +38 -11
  18. package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
  19. package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
  20. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +67 -0
  21. package/src/charts/bar-chart/test/bar-chart.test.tsx +79 -0
  22. package/src/charts/bar-list-chart/bar-list-chart.tsx +45 -2
  23. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +38 -0
  24. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  25. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  26. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  27. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  28. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  29. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  30. package/src/charts/heatmap-chart/heatmap-chart.module.scss +21 -16
  31. package/src/charts/heatmap-chart/heatmap-chart.tsx +45 -28
  32. package/src/charts/heatmap-chart/private/build-calendar-data.ts +3 -0
  33. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  34. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +51 -12
  35. package/src/charts/heatmap-chart/types.ts +2 -2
  36. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  37. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  38. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  39. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  40. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  41. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  42. package/src/charts/line-chart/line-chart.module.scss +10 -18
  43. package/src/charts/line-chart/line-chart.tsx +109 -25
  44. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  45. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  46. package/src/charts/line-chart/test/line-chart.test.tsx +74 -3
  47. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  48. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  49. package/src/charts/line-chart/types.ts +4 -1
  50. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  51. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  52. package/src/charts/pie-chart/test/pie-chart.test.tsx +56 -0
  53. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  54. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  55. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  56. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  57. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  58. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  59. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  60. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  61. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  62. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  63. package/src/charts/private/test/time-axis.test.ts +9 -69
  64. package/src/charts/private/time-axis-options.ts +106 -0
  65. package/src/charts/private/time-axis.ts +318 -136
  66. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  67. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  68. package/src/charts/sparkline/sparkline.tsx +9 -3
  69. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  70. package/src/charts/sparkline/types.ts +2 -2
  71. package/src/components/legend/private/base-legend.module.scss +9 -5
  72. package/src/components/tooltip/accessible-tooltip.tsx +38 -10
  73. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  74. package/src/components/tooltip/private/bounded-tooltip.tsx +172 -0
  75. package/src/components/tooltip/test/accessible-tooltip.test.tsx +67 -0
  76. package/src/components/tooltip/test/bounded-tooltip.test.tsx +129 -0
  77. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +279 -0
  78. package/src/components/tooltip/xy-chart-tooltip.tsx +288 -0
  79. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  80. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +17 -10
  81. package/src/hooks/test/use-xychart-theme.test.tsx +63 -5
  82. package/src/hooks/use-xychart-theme.ts +33 -23
  83. package/src/index.ts +4 -0
  84. package/src/providers/chart-context/global-charts-provider.tsx +64 -54
  85. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  86. package/src/providers/chart-context/index.ts +1 -0
  87. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  88. package/src/providers/chart-context/private/series-palette.ts +35 -0
  89. package/src/providers/chart-context/test/catalog-pointers.test.ts +61 -0
  90. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  91. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -18
  92. package/src/providers/chart-context/themes.ts +7 -47
  93. package/src/providers/chart-context/types.ts +3 -0
  94. package/src/styles/chart-scope.scss +63 -58
  95. package/src/styles/test/chart-scope.test.ts +25 -40
  96. package/src/test-utils/series-fixtures.ts +31 -0
  97. package/src/types.ts +103 -73
  98. package/src/utils/bucket-info.ts +87 -0
  99. package/src/utils/color-utils.ts +0 -27
  100. package/src/utils/date-formatting.ts +174 -0
  101. package/src/utils/get-styles.ts +7 -1
  102. package/src/utils/index.ts +3 -0
  103. package/src/utils/test/bucket-info.test.ts +185 -0
  104. package/src/utils/test/color-utils.test.ts +1 -48
  105. package/src/utils/test/date-formatting.test.ts +89 -0
  106. package/src/utils/test/get-styles.test.ts +10 -7
  107. package/src/utils/test/merge-themes.test.ts +18 -46
  108. package/src/visx/types.ts +29 -2
  109. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  110. package/src/providers/chart-context/private/theme-override-vars.ts +0 -87
  111. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -50
  112. package/src/providers/chart-context/test/sparse-theme.test.ts +0 -35
  113. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  114. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -118
  115. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -47
  116. package/src/styles/test/wpds-fallbacks.test.ts +0 -131
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @jest-environment <rootDir>/tests/environment-los-angeles.mjs
3
+ */
4
+ import { renderHook } from '@testing-library/react';
5
+ import { GlobalChartsProvider } from '../../../../providers';
6
+ import { useBarChartOptions } from '../use-bar-chart-options';
7
+ import type { SeriesData } from '../../../../types';
8
+ import type { ReactNode } from 'react';
9
+
10
+ // The runtime locale stays en-US: a bucket labeled in German, on Tokyo's
11
+ // calendar day, can only come from the provider.
12
+
13
+ // 15:30 UTC is Aug 2 in Los Angeles and Aug 3 in Tokyo.
14
+ const START = new Date( '2026-08-02T15:30:00Z' );
15
+
16
+ const daily: SeriesData[] = [
17
+ {
18
+ label: 'Series A',
19
+ data: Array.from( { length: 14 }, ( _, i ) => ( {
20
+ date: new Date( START.getTime() + i * 24 * 60 * 60 * 1000 ),
21
+ value: 10 + i,
22
+ } ) ),
23
+ options: {},
24
+ },
25
+ ];
26
+
27
+ const withProvider =
28
+ ( props: { locale?: string; timeZone?: string } = {} ) =>
29
+ ( { children }: { children: ReactNode } ) => (
30
+ <GlobalChartsProvider { ...props }>{ children }</GlobalChartsProvider>
31
+ );
32
+
33
+ const optionsFor = (
34
+ props?: { locale?: string; timeZone?: string },
35
+ options?: Parameters< typeof useBarChartOptions >[ 2 ]
36
+ ) =>
37
+ renderHook( () => useBarChartOptions( daily, false, options ), {
38
+ wrapper: withProvider( props ),
39
+ } ).result.current;
40
+
41
+ describe( 'useBarChartOptions with a host formatting context', () => {
42
+ it( 'labels the tooltip bucket in the provider locale and time zone', () => {
43
+ const { tooltip } = optionsFor( { locale: 'de-DE', timeZone: 'Asia/Tokyo' } );
44
+
45
+ expect( tooltip.labelFormatter( START.getTime(), 0, [] ) ).toBe( '3. August 2026' );
46
+ } );
47
+
48
+ it( 'labels a weekly bucket in the provider locale and time zone', () => {
49
+ const { tooltip } = optionsFor(
50
+ { locale: 'de-DE', timeZone: 'Asia/Tokyo' },
51
+ { axis: { x: { tickResolution: 'week' } } }
52
+ );
53
+
54
+ expect( tooltip.labelFormatter( START.getTime(), 0, [] ) ).toBe( 'Week of 3. August 2026' );
55
+ } );
56
+
57
+ it( 'labels the axis ticks in the provider locale and time zone', () => {
58
+ const { axis } = optionsFor( { locale: 'de-DE', timeZone: 'Asia/Tokyo' } );
59
+
60
+ expect( axis.x.tickFormat( START, 0, [] ) ).toBe( '3. Aug.' );
61
+ } );
62
+
63
+ it( 'falls back to the runtime locale and zone when the provider supplies none', () => {
64
+ const { tooltip, axis } = optionsFor();
65
+
66
+ expect( tooltip.labelFormatter( START.getTime(), 0, [] ) ).toBe( 'August 2, 2026' );
67
+ expect( axis.x.tickFormat( START, 0, [] ) ).toBe( 'Aug 2' );
68
+ } );
69
+ } );
@@ -2,10 +2,19 @@ import { formatNumberCompact } from '@automattic/number-formatters';
2
2
  import { __, sprintf } from '@wordpress/i18n';
3
3
  import { useMemo } from 'react';
4
4
  import { useDeepMemo } from '../../../hooks';
5
- import { getBandTickValues, getBucketResolution, getFormatter } from '../../private/time-axis';
5
+ import { useChartFormatting } from '../../../providers';
6
+ import { getBucketResolution } from '../../../utils/bucket-info';
7
+ import { createDateFormatter } from '../../../utils/date-formatting';
8
+ import { getBandTickValues, getFormatter } from '../../private/time-axis';
6
9
  import { TruncatedXTickComponent, TruncatedYTickComponent } from './truncated-tick-component';
7
10
  import type { EnhancedDataPoint } from '../../../hooks/use-zero-value-display';
8
- import type { DataPointDate, BaseChartProps, SeriesData, TickResolution } from '../../../types';
11
+ import type {
12
+ DataPointDate,
13
+ BaseChartProps,
14
+ ChartFormatting,
15
+ SeriesData,
16
+ TickResolution,
17
+ } from '../../../types';
9
18
  import type { TickFormatter } from '@visx/axis';
10
19
 
11
20
  /** Outer padding of the category band scale (space at the chart edges). */
@@ -24,7 +33,7 @@ const TOOLTIP_FORMAT_BY_RESOLUTION: Record<
24
33
  Exclude< TickResolution, 'week' >,
25
34
  Intl.DateTimeFormatOptions
26
35
  > = {
27
- hour: { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', hour12: true },
36
+ hour: { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric' },
28
37
  day: { year: 'numeric', month: 'long', day: 'numeric' },
29
38
  month: { year: 'numeric', month: 'long' },
30
39
  year: { year: 'numeric' },
@@ -36,31 +45,33 @@ const TOOLTIP_FORMAT_BY_RESOLUTION: Record<
36
45
  *
37
46
  * @param data - Date-based series, already parsed and sorted by `useChartDataTransform`.
38
47
  * @param tickResolution - Caller-declared bucket resolution, when known.
48
+ * @param formatting - Host locale and time zone.
39
49
  * @return Tooltip label formatter.
40
50
  */
41
- const getTooltipFormatter = ( data: SeriesData[], tickResolution?: TickResolution ) => {
51
+ const getTooltipFormatter = (
52
+ data: SeriesData[],
53
+ tickResolution: TickResolution | undefined,
54
+ formatting: ChartFormatting
55
+ ) => {
42
56
  // Only a declared 'week' reaches this branch: seven-day spacing is
43
57
  // indistinguishable from sparse daily data, so inference reports 'day'.
44
58
  if ( tickResolution === 'week' ) {
59
+ const formatDay = createDateFormatter( TOOLTIP_FORMAT_BY_RESOLUTION.day, formatting );
45
60
  return ( timestamp: number ) =>
46
61
  sprintf(
47
62
  /* translators: %s is the first day of the week the bar covers. */
48
63
  __( 'Week of %s', 'jetpack-charts' ),
49
- new Date( timestamp ).toLocaleDateString( undefined, {
50
- year: 'numeric',
51
- month: 'long',
52
- day: 'numeric',
53
- } )
64
+ formatDay( timestamp )
54
65
  );
55
66
  }
56
67
 
57
- // Fall back to the day format rather than `undefined` options, which would
58
- // print a full locale date-time for an unrecognised `tickResolution`.
68
+ // Fall back to the day format rather than empty options, which would print a
69
+ // bare numeric date for an unrecognized `tickResolution`.
59
70
  const format =
60
71
  TOOLTIP_FORMAT_BY_RESOLUTION[ getBucketResolution( data, tickResolution ) ] ??
61
72
  TOOLTIP_FORMAT_BY_RESOLUTION.day;
62
73
 
63
- return ( timestamp: number ) => new Date( timestamp ).toLocaleString( undefined, format );
74
+ return createDateFormatter( format, formatting );
64
75
  };
65
76
 
66
77
  const identity = ( label: string ) => label;
@@ -152,6 +163,7 @@ export function useBarChartOptions(
152
163
  // Callers reasonably pass an object literal, which is a fresh reference every
153
164
  // render and would defeat every memo below.
154
165
  const stableOptions = useDeepMemo( options );
166
+ const formatting = useChartFormatting();
155
167
 
156
168
  // `labelOverflow` and `tickResolution` are consumed by this hook rather than
157
169
  // forwarded — visx has an axis prop for neither — and `tickFormat` is merged
@@ -202,11 +214,11 @@ export function useBarChartOptions(
202
214
  // formatter, which narrows with the overall span as well as the bucket
203
215
  // size; the tooltip stays at the bucket's own granularity.
204
216
  const hasLabels = Boolean( data?.[ 0 ]?.data?.[ 0 ]?.label );
205
- const timeTickFormatter = hasLabels ? null : getFormatter( data, tickResolution );
217
+ const timeTickFormatter = hasLabels ? null : getFormatter( data, tickResolution, formatting );
206
218
  const labelFormatter = timeTickFormatter ? byBucket( timeTickFormatter ) : identity;
207
219
  const tooltipDatumFormatter = hasLabels
208
220
  ? labelFormatter
209
- : byBucket( getTooltipFormatter( data, tickResolution ) );
221
+ : byBucket( getTooltipFormatter( data, tickResolution, formatting ) );
210
222
  const valueFormatter = formatNumberCompact as TickFormatter< unknown >;
211
223
 
212
224
  const bandDomain = timeTickFormatter ? getBandDomain( data, isSeriesRendered ) : null;
@@ -244,7 +256,7 @@ export function useBarChartOptions(
244
256
  yScale: bandScale,
245
257
  },
246
258
  };
247
- }, [ data, tickResolution, isSeriesRendered ] );
259
+ }, [ data, tickResolution, isSeriesRendered, formatting ] );
248
260
 
249
261
  return useMemo( () => {
250
262
  const orientationKey = horizontal ? 'horizontal' : 'vertical';
@@ -0,0 +1,67 @@
1
+ import { render } from '@testing-library/react';
2
+ import { GlobalChartsProvider } from '../../../providers';
3
+ import BarChart from '../bar-chart';
4
+ import type { ReactNode } from 'react';
5
+
6
+ // jsdom gives every element a zero-sized box, so visx can never resolve a
7
+ // pointer to a datum here. Standing `XYChart` in for a prop recorder keeps this
8
+ // test on the part jsdom can answer: that the handlers reach the chart at all.
9
+ // Whether a bar then reports the datum under the pointer is a browser question.
10
+ const mockXYChart = jest.fn();
11
+ jest.mock( '@visx/xychart', () => {
12
+ const actual = jest.requireActual( '@visx/xychart' );
13
+
14
+ return {
15
+ __esModule: true,
16
+ ...actual,
17
+ XYChart: ( props: { children?: ReactNode } ) => {
18
+ mockXYChart( props );
19
+ return <svg />;
20
+ },
21
+ };
22
+ } );
23
+
24
+ const mockRefCallback = jest.fn();
25
+ jest.mock( '../../../hooks/use-element-size', () => ( {
26
+ useElementSize: () => [ mockRefCallback, 500, 300 ],
27
+ } ) );
28
+
29
+ const DATA = [
30
+ {
31
+ label: 'Series A',
32
+ data: [
33
+ { date: new Date( '2024-01-01' ), value: 10 },
34
+ { date: new Date( '2024-01-02' ), value: 20 },
35
+ ],
36
+ options: {},
37
+ },
38
+ ];
39
+
40
+ /**
41
+ * Render the chart with the recorder standing in for `XYChart`.
42
+ *
43
+ * @param props - Props layered onto the defaults.
44
+ * @return The render result.
45
+ */
46
+ function renderBarChart( props = {} ) {
47
+ return render(
48
+ <GlobalChartsProvider>
49
+ <BarChart width={ 500 } height={ 300 } data={ DATA } { ...props } />
50
+ </GlobalChartsProvider>
51
+ );
52
+ }
53
+
54
+ describe( 'BarChart pointer events', () => {
55
+ beforeEach( () => mockXYChart.mockClear() );
56
+
57
+ test( 'hands the pointer handlers to the chart, so a bar can be clicked', () => {
58
+ const onPointerDown = jest.fn();
59
+ const onPointerUp = jest.fn();
60
+
61
+ renderBarChart( { onPointerDown, onPointerUp } );
62
+
63
+ const props = mockXYChart.mock.calls.at( -1 )[ 0 ];
64
+ expect( props.onPointerDown ).toBe( onPointerDown );
65
+ expect( props.onPointerUp ).toBe( onPointerUp );
66
+ } );
67
+ } );
@@ -996,6 +996,85 @@ describe( 'BarChart', () => {
996
996
  } );
997
997
  } );
998
998
 
999
+ describe( 'Activation', () => {
1000
+ const SERIES_A = {
1001
+ label: 'Series A',
1002
+ data: [
1003
+ { date: new Date( '2024-01-01' ), value: 10 },
1004
+ { date: new Date( '2024-01-02' ), value: 20 },
1005
+ ],
1006
+ options: {},
1007
+ };
1008
+ const SERIES_B = {
1009
+ label: 'Series B',
1010
+ data: [
1011
+ { date: new Date( '2024-01-01' ), value: 15 },
1012
+ { date: new Date( '2024-01-02' ), value: 25 },
1013
+ ],
1014
+ options: {},
1015
+ };
1016
+
1017
+ test( 'Enter hands the selected bar to onDatumActivate', async () => {
1018
+ const user = userEvent.setup();
1019
+ const onDatumActivate = jest.fn();
1020
+ renderWithTheme( { withTooltips: true, data: [ SERIES_A ], onDatumActivate } );
1021
+
1022
+ screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
1023
+ await user.keyboard( '{ArrowRight}{ArrowRight}{Enter}' );
1024
+
1025
+ expect( onDatumActivate ).toHaveBeenCalledTimes( 1 );
1026
+ expect( onDatumActivate ).toHaveBeenCalledWith( {
1027
+ datum: SERIES_A.data[ 1 ],
1028
+ index: 1,
1029
+ key: 'Series A',
1030
+ } );
1031
+ } );
1032
+
1033
+ test( 'Space activates the selected bar too', async () => {
1034
+ const user = userEvent.setup();
1035
+ const onDatumActivate = jest.fn();
1036
+ renderWithTheme( { withTooltips: true, data: [ SERIES_A ], onDatumActivate } );
1037
+
1038
+ screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
1039
+ await user.keyboard( '{ArrowRight}[Space]' );
1040
+
1041
+ expect( onDatumActivate ).toHaveBeenCalledWith( {
1042
+ datum: SERIES_A.data[ 0 ],
1043
+ index: 0,
1044
+ key: 'Series A',
1045
+ } );
1046
+ } );
1047
+
1048
+ // The navigation index strides series-major within each data point, the
1049
+ // order the highlight outlines bars in, so the second stop is the second
1050
+ // series' first bar.
1051
+ test( 'walks the series at each data point before the next point', async () => {
1052
+ const user = userEvent.setup();
1053
+ const onDatumActivate = jest.fn();
1054
+ renderWithTheme( { withTooltips: true, data: [ SERIES_A, SERIES_B ], onDatumActivate } );
1055
+
1056
+ screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
1057
+ await user.keyboard( '{ArrowRight}{ArrowRight}{Enter}' );
1058
+
1059
+ expect( onDatumActivate ).toHaveBeenCalledWith( {
1060
+ datum: SERIES_B.data[ 0 ],
1061
+ index: 0,
1062
+ key: 'Series B',
1063
+ } );
1064
+ } );
1065
+
1066
+ test( 'Enter with no bar selected activates nothing', async () => {
1067
+ const user = userEvent.setup();
1068
+ const onDatumActivate = jest.fn();
1069
+ renderWithTheme( { withTooltips: true, data: [ SERIES_A ], onDatumActivate } );
1070
+
1071
+ screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
1072
+ await user.keyboard( '{Enter}' );
1073
+
1074
+ expect( onDatumActivate ).not.toHaveBeenCalled();
1075
+ } );
1076
+ } );
1077
+
999
1078
  describe( 'Arrow Key Navigation', () => {
1000
1079
  test( 'right arrow key navigates to next data point', async () => {
1001
1080
  const user = userEvent.setup();
@@ -2,8 +2,10 @@ import { formatNumberCompact } from '@automattic/number-formatters';
2
2
  import { Group } from '@visx/group';
3
3
  import { createScale, scaleBand } from '@visx/scale';
4
4
  import { Text, type TextProps } from '@visx/text';
5
+ import clsx from 'clsx';
5
6
  import { useContext, useMemo } from 'react';
6
- import { GlobalChartsContext, GlobalChartsProvider } from '../../providers';
7
+ import { GlobalChartsContext, GlobalChartsProvider, useGlobalChartsContext } from '../../providers';
8
+ import { isValidHexColor, lightenHexColor } from '../../utils';
7
9
  import { BarChartUnresponsive } from '../bar-chart';
8
10
  import { withResponsive } from '../private/with-responsive';
9
11
  import type { SeriesData } from '../..';
@@ -211,6 +213,14 @@ const getDefaultYOffset = (
211
213
  return -( barThickness + GAP_BETWEEN_BARS );
212
214
  };
213
215
 
216
+ // How far a single-series bar fill travels from white toward the series color.
217
+ //
218
+ // With one series the label is drawn *on* the bar, so the fill is a text background and has to keep
219
+ // contrast with `--a8c-charts-color-label`. At full strength the catalog seed `#3858e9` leaves
220
+ // `#1e1e1e` at 2.97:1; this puts it at 9.0:1. Multi-series bars keep full strength — the label is
221
+ // lifted clear of them, so nothing is read against the fill.
222
+ const BAR_TINT_TOWARD_SERIES = 0.4;
223
+
214
224
  const BarListChartInternal: FC< BarListChartProps > = ( {
215
225
  data,
216
226
  width,
@@ -222,8 +232,38 @@ const BarListChartInternal: FC< BarListChartProps > = ( {
222
232
  bottom: 0,
223
233
  top: 0,
224
234
  },
235
+ className,
225
236
  ...rest
226
237
  } ) => {
238
+ const { getElementStyles } = useGlobalChartsContext();
239
+
240
+ // A series carrying its own stroke is left alone — the consumer asked for that exact color.
241
+ const tintedData = useMemo( () => {
242
+ if ( data.length > 1 ) {
243
+ return data;
244
+ }
245
+
246
+ return data.map( ( series, index ) => {
247
+ if ( series.options?.stroke ) {
248
+ return series;
249
+ }
250
+
251
+ const { color } = getElementStyles( { data: series, index } );
252
+
253
+ if ( ! isValidHexColor( color ) ) {
254
+ return series;
255
+ }
256
+
257
+ return {
258
+ ...series,
259
+ options: {
260
+ ...series.options,
261
+ stroke: lightenHexColor( color, 1 - BAR_TINT_TOWARD_SERIES ),
262
+ },
263
+ };
264
+ } );
265
+ }, [ data, getElementStyles ] );
266
+
227
267
  const chartOptions = useMemo( () => {
228
268
  const isMultiSeries = data.length > 1;
229
269
 
@@ -260,7 +300,10 @@ const BarListChartInternal: FC< BarListChartProps > = ( {
260
300
  <BarChartUnresponsive
261
301
  orientation="horizontal"
262
302
  gridVisibility={ 'none' }
263
- data={ data }
303
+ // The root carries `bar-chart` too, since a bar list renders through one. This class is
304
+ // how a consumer reaches a bar list without reaching every bar chart.
305
+ className={ clsx( 'bar-list-chart', className ) }
306
+ data={ tintedData }
264
307
  width={ width }
265
308
  height={ height }
266
309
  margin={ margin }
@@ -66,4 +66,42 @@ describe( 'BarListChart', () => {
66
66
  renderChart( { data: [] } );
67
67
  expect( screen.queryByText( 'Behemoth hat ' ) ).not.toBeInTheDocument();
68
68
  } );
69
+
70
+ describe( 'single-series bar tint', () => {
71
+ // visx renders the bars, so they carry no attribute of our own to query by.
72
+ /* eslint-disable testing-library/no-node-access */
73
+ const barFills = () =>
74
+ Array.from(
75
+ screen.getByRole( 'grid' ).querySelectorAll< SVGRectElement >( '.visx-bar-group rect' )
76
+ ).map( bar => bar.getAttribute( 'fill' ) );
77
+ /* eslint-enable testing-library/no-node-access */
78
+
79
+ // `lightenHexColor( '#3858e9', 1 - BAR_TINT_TOWARD_SERIES )` — the catalog seed traveling
80
+ // 40% from white toward the series color. Asserted as a literal so a change to the tint has
81
+ // to be made deliberately here rather than tracking the implementation.
82
+ const TINTED_SEED = '#afbcf6';
83
+
84
+ test( 'tints the bar so the label it carries stays readable', () => {
85
+ renderChart();
86
+ expect( barFills() ).not.toHaveLength( 0 );
87
+ barFills().forEach( fill => expect( fill ).toBe( TINTED_SEED ) );
88
+ } );
89
+
90
+ test( 'leaves a series carrying its own stroke at full strength', () => {
91
+ renderChart( {
92
+ data: salesByProduct.map( series => ( {
93
+ ...series,
94
+ options: { ...series.options, stroke: '#ff0000' },
95
+ } ) ),
96
+ } );
97
+ expect( barFills() ).not.toHaveLength( 0 );
98
+ barFills().forEach( fill => expect( fill ).toBe( '#ff0000' ) );
99
+ } );
100
+
101
+ test( 'leaves multi-series bars at full strength, where the label is lifted clear', () => {
102
+ renderChart( { data: salesByChannel } );
103
+ expect( barFills() ).not.toHaveLength( 0 );
104
+ expect( barFills() ).not.toContain( TINTED_SEED );
105
+ } );
106
+ } );
69
107
  } );
@@ -1,4 +1,5 @@
1
1
  .conversion-funnel-chart {
2
+ position: relative;
2
3
 
3
4
  &--loading {
4
5
  opacity: 0.6;
@@ -14,21 +15,29 @@
14
15
  overflow: hidden;
15
16
  color: var(--a8c-charts-color-label);
16
17
  text-overflow: ellipsis;
17
- font-size: var(--wpds-typography-font-size-xl, 20px);
18
+ font-size: var(--wpds-typography-font-size-xl);
18
19
  font-style: normal;
19
- font-weight: var(--wpds-typography-font-weight-emphasis, 600);
20
- line-height: var(--wpds-typography-line-height-sm, 20px);
20
+ font-weight: var(--wpds-typography-font-weight-emphasis);
21
+ line-height: var(--wpds-typography-line-height-sm);
21
22
  margin: 0;
22
23
  }
23
24
 
24
25
  .change-indicator {
25
26
  overflow: hidden;
26
27
  text-overflow: ellipsis;
27
- font-size: var(--wpds-typography-font-size-md, 13px);
28
+ font-size: var(--wpds-typography-font-size-md);
28
29
  font-style: normal;
29
- font-weight: var(--wpds-typography-font-weight-emphasis, 600);
30
- line-height: var(--wpds-typography-line-height-sm, 20px);
30
+ font-weight: var(--wpds-typography-font-weight-emphasis);
31
+ line-height: var(--wpds-typography-line-height-sm);
31
32
  margin: 0;
33
+
34
+ &--positive {
35
+ color: var(--a8c-charts-color-trend-up);
36
+ }
37
+
38
+ &--negative {
39
+ color: var(--a8c-charts-color-trend-down);
40
+ }
32
41
  }
33
42
 
34
43
  .funnel-container {
@@ -44,8 +53,8 @@
44
53
 
45
54
  &--animated {
46
55
  transition:
47
- opacity var(--wpds-motion-duration-lg, 300ms)
48
- var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
56
+ opacity var(--wpds-motion-duration-lg)
57
+ var(--wpds-motion-easing-subtle);
49
58
  }
50
59
 
51
60
  &--blurred {
@@ -55,9 +64,9 @@
55
64
 
56
65
  .step-label {
57
66
  color: var(--a8c-charts-color-label-secondary);
58
- font-size: var(--wpds-typography-font-size-sm, 12px);
59
- font-weight: var(--wpds-typography-font-weight-default, 400);
60
- line-height: var(--wpds-typography-line-height-xs, 16px);
67
+ font-size: var(--wpds-typography-font-size-sm);
68
+ font-weight: var(--wpds-typography-font-weight-default);
69
+ line-height: var(--wpds-typography-line-height-xs);
61
70
  white-space: nowrap;
62
71
  overflow: hidden;
63
72
  text-overflow: ellipsis;
@@ -65,14 +74,15 @@
65
74
 
66
75
  .step-rate {
67
76
  color: var(--a8c-charts-color-label);
68
- font-size: var(--wpds-typography-font-size-md, 13px);
69
- font-weight: var(--wpds-typography-font-weight-emphasis, 600);
70
- line-height: var(--wpds-typography-line-height-xs, 16px);
77
+ font-size: var(--wpds-typography-font-size-md);
78
+ font-weight: var(--wpds-typography-font-weight-emphasis);
79
+ line-height: var(--wpds-typography-line-height-xs);
71
80
  }
72
81
 
73
82
  .bar-container {
74
83
  flex: 1;
75
84
  border-radius: var(--a8c-charts-border-radius-bar);
85
+ background-color: var(--a8c-charts-color-surface-secondary);
76
86
  position: relative;
77
87
  cursor: pointer;
78
88
  }
@@ -108,30 +118,30 @@
108
118
  background: var(--a8c-charts-color-surface);
109
119
 
110
120
  // Override .visx-tooltip inline styles.
111
- border-radius: var(--wpds-border-radius-md, 4px) !important;
112
- padding: var(--wpds-dimension-padding-md, 12px) !important;
121
+ border-radius: var(--wpds-border-radius-md) !important;
122
+ padding: var(--wpds-dimension-padding-md) !important;
113
123
  box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005) !important;
114
124
 
115
125
  }
116
126
 
117
127
  .tooltip-title {
118
128
  color: var(--a8c-charts-color-label);
119
- font-size: var(--wpds-typography-font-size-sm, 12px);
129
+ font-size: var(--wpds-typography-font-size-sm);
120
130
  font-style: normal;
121
- font-weight: var(--wpds-typography-font-weight-default, 400);
122
- line-height: var(--wpds-typography-line-height-xs, 16px);
131
+ font-weight: var(--wpds-typography-font-weight-default);
132
+ line-height: var(--wpds-typography-line-height-xs);
123
133
  }
124
134
 
125
135
  .tooltip-content {
126
136
  color: var(--a8c-charts-color-label);
127
- font-size: var(--wpds-typography-font-size-md, 13px);
137
+ font-size: var(--wpds-typography-font-size-md);
128
138
  font-style: normal;
129
- font-weight: var(--wpds-typography-font-weight-emphasis, 600);
130
- line-height: var(--wpds-typography-line-height-sm, 20px);
139
+ font-weight: var(--wpds-typography-font-weight-emphasis);
140
+ line-height: var(--wpds-typography-line-height-sm);
131
141
  }
132
142
 
133
143
  .empty-state {
134
144
  text-align: center;
135
145
  color: var(--a8c-charts-color-label-secondary);
136
- font-size: var(--wpds-typography-font-size-lg, 15px);
146
+ font-size: var(--wpds-typography-font-size-lg);
137
147
  }