@automattic/charts 3.1.0 → 3.2.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 (41) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/TOKENS.md +51 -12
  3. package/dist/index.cjs +307 -213
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +7 -1
  6. package/dist/index.d.cts +223 -127
  7. package/dist/index.d.ts +223 -127
  8. package/dist/index.js +306 -212
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -8
  11. package/src/charts/area-chart/area-chart.tsx +2 -2
  12. package/src/charts/area-chart/test/area-chart.test.tsx +45 -0
  13. package/src/charts/bar-chart/bar-chart.tsx +36 -10
  14. package/src/charts/bar-chart/private/use-bar-chart-options.ts +19 -12
  15. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +67 -0
  16. package/src/charts/bar-chart/test/bar-chart.test.tsx +101 -0
  17. package/src/charts/bar-list-chart/bar-list-chart.tsx +40 -2
  18. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +38 -0
  19. package/src/charts/line-chart/line-chart.tsx +18 -3
  20. package/src/charts/line-chart/test/line-chart.test.tsx +98 -0
  21. package/src/charts/sparkline/sparkline.tsx +9 -3
  22. package/src/charts/sparkline/types.ts +1 -1
  23. package/src/components/tooltip/accessible-tooltip.tsx +32 -2
  24. package/src/components/tooltip/test/accessible-tooltip.test.tsx +68 -0
  25. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +4 -4
  26. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +17 -10
  27. package/src/hooks/test/use-xychart-theme.test.tsx +50 -5
  28. package/src/hooks/use-xychart-theme.ts +14 -20
  29. package/src/providers/chart-context/global-charts-provider.tsx +44 -33
  30. package/src/providers/chart-context/private/series-palette.ts +45 -0
  31. package/src/providers/chart-context/private/theme-override-vars.ts +46 -2
  32. package/src/providers/chart-context/private/with-catalog-pointers.ts +45 -2
  33. package/src/providers/chart-context/test/sparse-theme.test.ts +16 -2
  34. package/src/providers/chart-context/test/theme-override-vars.test.ts +65 -0
  35. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +8 -1
  36. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +57 -0
  37. package/src/providers/chart-context/themes.ts +8 -3
  38. package/src/styles/chart-scope.scss +52 -24
  39. package/src/styles/test/chart-scope.test.ts +22 -9
  40. package/src/types.ts +148 -31
  41. package/src/utils/get-styles.ts +7 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Display charts within Automattic products.",
5
5
  "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
6
6
  "bugs": {
@@ -63,7 +63,7 @@
63
63
  "typecheck": "tsgo --noEmit"
64
64
  },
65
65
  "dependencies": {
66
- "@automattic/number-formatters": "^1.2.7",
66
+ "@automattic/number-formatters": "^1.2.8",
67
67
  "@babel/runtime": "7.29.7",
68
68
  "@react-spring/web": "10.1.1",
69
69
  "@visx/annotation": "^4.0.0",
@@ -84,8 +84,8 @@
84
84
  "@visx/xychart": "^4.0.0",
85
85
  "@wordpress/i18n": "^6.0.0",
86
86
  "@wordpress/icons": "^15.0.0",
87
- "@wordpress/theme": "1.1.0",
88
- "@wordpress/ui": "0.19.0",
87
+ "@wordpress/theme": "1.2.0",
88
+ "@wordpress/ui": "0.20.0",
89
89
  "clsx": "2.1.1",
90
90
  "date-fns": "^4.1.0",
91
91
  "deepmerge": "4.3.1",
@@ -103,16 +103,16 @@
103
103
  "@testing-library/jest-dom": "^6.0.0",
104
104
  "@testing-library/react": "^16.0.0",
105
105
  "@testing-library/user-event": "^14.4.3",
106
- "@tsdown/css": "0.22.2",
106
+ "@tsdown/css": "0.22.14",
107
107
  "@types/jest": "^30.0.0",
108
108
  "@types/node": "^24.12.0",
109
- "@types/react": "18.3.28",
109
+ "@types/react": "18.3.31",
110
110
  "@types/react-dom": "18.3.7",
111
111
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
112
112
  "@visx/glyph": "4.0.0",
113
113
  "@wordpress/components": "37.0.0",
114
114
  "@wordpress/element": "8.3.0",
115
- "@wordpress/private-apis": "1.52.0",
115
+ "@wordpress/private-apis": "1.53.0",
116
116
  "babel-jest": "30.4.1",
117
117
  "identity-obj-proxy": "^3.0.0",
118
118
  "jest": "30.4.2",
@@ -122,7 +122,7 @@
122
122
  "react-dom": "18.3.1",
123
123
  "sass-embedded": "1.97.3",
124
124
  "storybook": "10.4.6",
125
- "tsdown": "0.22.2",
125
+ "tsdown": "0.22.14",
126
126
  "typescript": "6.0.3"
127
127
  },
128
128
  "peerDependencies": {
@@ -205,8 +205,8 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
205
205
  }, [ dataSorted, stacked, stackOffset, rescaleYOnVisibility ] );
206
206
 
207
207
  const chartOptions = useMemo( () => {
208
- const { tickResolution, ...xAxisOptions } = options?.axis?.x ?? {};
209
- const formatter = xAxisOptions.tickFormat || getFormatter( dataSorted, tickResolution );
208
+ const { tickResolution, tickFormat, ...xAxisOptions } = options?.axis?.x ?? {};
209
+ const formatter = tickFormat || getFormatter( dataSorted, tickResolution );
210
210
 
211
211
  return {
212
212
  axis: {
@@ -112,6 +112,51 @@ describe( 'AreaChart', () => {
112
112
  } );
113
113
  } );
114
114
 
115
+ describe( 'X-Axis Ticks', () => {
116
+ const monthlySeries = [
117
+ {
118
+ label: 'Series A',
119
+ data: [
120
+ { date: new Date( '2024-01-01' ), value: 10 },
121
+ { date: new Date( '2024-04-01' ), value: 20 },
122
+ { date: new Date( '2024-07-01' ), value: 30 },
123
+ { date: new Date( '2024-10-01' ), value: 40 },
124
+ { date: new Date( '2025-03-01' ), value: 50 },
125
+ ],
126
+ },
127
+ ];
128
+
129
+ test( 'keeps the derived month formatter when tickFormat is passed as undefined', () => {
130
+ renderWithProvider( {
131
+ options: { axis: { x: { tickFormat: undefined } } },
132
+ data: monthlySeries,
133
+ } );
134
+
135
+ // January is absent: formatMonthOrYearTick renders it as the year instead.
136
+ const ticks = screen.getAllByText( /^(Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/ );
137
+ expect( ticks.length ).toBeGreaterThan( 1 );
138
+ expect(
139
+ screen.queryByText( /^(February|March|April|June|July|August|September|October)$/ )
140
+ ).not.toBeInTheDocument();
141
+ } );
142
+
143
+ test( 'honors an explicit tickFormat over the derived formatter', () => {
144
+ renderWithProvider( {
145
+ options: {
146
+ axis: {
147
+ x: {
148
+ tickFormat: ( date: Date | number ) =>
149
+ `tick-${ new Date( Number( date ) ).getUTCMonth() }`,
150
+ },
151
+ },
152
+ },
153
+ data: monthlySeries,
154
+ } );
155
+
156
+ expect( screen.getAllByText( /^tick-\d+$/ ).length ).toBeGreaterThan( 0 );
157
+ } );
158
+ } );
159
+
115
160
  describe( 'Stacking', () => {
116
161
  test( 'is stacked by default', () => {
117
162
  renderWithProvider();
@@ -128,6 +128,9 @@ const BarChartInternal: FC< BarChartProps > = ( {
128
128
  animation,
129
129
  children,
130
130
  gap = 'md',
131
+ onPointerDown,
132
+ onPointerUp,
133
+ onDatumActivate,
131
134
  } ) => {
132
135
  const legendInteractive = legend.interactive ?? false;
133
136
  const legendCollapseGroups = legend.collapseGroups ?? false;
@@ -196,16 +199,6 @@ const BarChartInternal: FC< BarChartProps > = ( {
196
199
  Math.max( 0, ...primarySeriesForNav.map( s => s.data?.length || 0 ) ) *
197
200
  primarySeriesForNav.length;
198
201
 
199
- // Use the keyboard navigation hook
200
- const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
201
- selectedIndex,
202
- setSelectedIndex,
203
- isNavigating,
204
- setIsNavigating,
205
- chartRef,
206
- totalPoints,
207
- } );
208
-
209
202
  // Add visibility information from the shared legend state.
210
203
  const seriesWithVisibility = useMemo(
211
204
  () =>
@@ -244,6 +237,37 @@ const BarChartInternal: FC< BarChartProps > = ( {
244
237
  [ primaryEntries ]
245
238
  );
246
239
 
240
+ // Walks the selected index the way the highlight style does — series-major
241
+ // within each data point — so the bar handed on is the one outlined.
242
+ const activateSelectedBar = useCallback(
243
+ ( index: number ) => {
244
+ const primaryCount = primaryEntries.length;
245
+
246
+ if ( ! primaryCount ) {
247
+ return;
248
+ }
249
+
250
+ const dataPointIndex = Math.floor( index / primaryCount );
251
+ const series = primaryEntries[ index % primaryCount ]?.series;
252
+ const datum = series?.data[ dataPointIndex ];
253
+
254
+ if ( series && datum ) {
255
+ onDatumActivate?.( { datum, index: dataPointIndex, key: series.label } );
256
+ }
257
+ },
258
+ [ primaryEntries, onDatumActivate ]
259
+ );
260
+
261
+ const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
262
+ selectedIndex,
263
+ setSelectedIndex,
264
+ isNavigating,
265
+ setIsNavigating,
266
+ chartRef,
267
+ totalPoints,
268
+ onActivate: activateSelectedBar,
269
+ } );
270
+
247
271
  const comparisonEntries = useMemo( () => {
248
272
  const primaryByGroup = new Map< string | undefined, { label: string; index: number } >(
249
273
  primaryEntries.map( ( { series, index } ) => [
@@ -575,6 +599,8 @@ const BarChartInternal: FC< BarChartProps > = ( {
575
599
  xScale={ xScale }
576
600
  yScale={ yScale }
577
601
  horizontal={ horizontal }
602
+ onPointerDown={ onPointerDown }
603
+ onPointerUp={ onPointerUp }
578
604
  pointerEventsDataKey="nearest"
579
605
  >
580
606
  { ! allSeriesHidden && (
@@ -154,23 +154,29 @@ export function useBarChartOptions(
154
154
  const stableOptions = useDeepMemo( options );
155
155
 
156
156
  // `labelOverflow` and `tickResolution` are consumed by this hook rather than
157
- // forwarded — visx has an axis prop for neither — so they are split off the
158
- // caller's axis options once, here, and only the rest reaches visx below.
157
+ // forwarded — visx has an axis prop for neither — and `tickFormat` is merged
158
+ // with the derived default explicitly, so an `undefined` passed by a caller
159
+ // cannot clobber it through the spread. They are split off the caller's axis
160
+ // options once, here, and only the rest reaches visx below.
159
161
  const axisConfig = useMemo( () => {
160
162
  const {
161
163
  labelOverflow: xLabelOverflow,
162
164
  tickResolution: xTickResolution,
165
+ tickFormat: xTickFormat,
163
166
  ...xAxisOptions
164
167
  } = stableOptions.axis?.x || {};
165
168
  const {
166
169
  labelOverflow: yLabelOverflow,
167
170
  tickResolution: yTickResolution,
171
+ tickFormat: yTickFormat,
168
172
  ...yAxisOptions
169
173
  } = stableOptions.axis?.y || {};
170
174
 
171
175
  return {
172
176
  xLabelOverflow,
173
177
  yLabelOverflow,
178
+ xTickFormat,
179
+ yTickFormat,
174
180
  xAxisOptions,
175
181
  yAxisOptions,
176
182
  // The dates sit on the x axis normally, and on the y axis when the
@@ -243,8 +249,8 @@ export function useBarChartOptions(
243
249
  return useMemo( () => {
244
250
  const orientationKey = horizontal ? 'horizontal' : 'vertical';
245
251
  const {
246
- xTickFormat,
247
- yTickFormat,
252
+ xTickFormat: defaultXTickFormat,
253
+ yTickFormat: defaultYTickFormat,
248
254
  tooltipLabelFormatter: defaultTooltipLabelFormatter,
249
255
  xAccessor,
250
256
  yAccessor,
@@ -294,10 +300,11 @@ export function useBarChartOptions(
294
300
  ...( stableOptions.yScale || {} ),
295
301
  ...( ! horizontal ? valueScaleDomainOverride : {} ),
296
302
  };
297
- const { xLabelOverflow, yLabelOverflow, xAxisOptions, yAxisOptions } = axisConfig;
298
- const providedToolTipLabelFormatter = horizontal
299
- ? yAxisOptions.tickFormat
300
- : xAxisOptions.tickFormat;
303
+ const { xLabelOverflow, yLabelOverflow, xTickFormat, yTickFormat, xAxisOptions, yAxisOptions } =
304
+ axisConfig;
305
+ // The dates sit on the y axis of a horizontal chart, so the caller's format
306
+ // for them moves with the orientation. It also labels the tooltip.
307
+ const dateAxisTickFormat = horizontal ? yTickFormat : xTickFormat;
301
308
 
302
309
  // A band scale has no ticks of its own for visx to ask for, so it samples
303
310
  // the domain by index and can miss the tick that dates the day or names the
@@ -306,7 +313,7 @@ export function useBarChartOptions(
306
313
  const { timeAxis } = defaultOptions;
307
314
  const dateAxisOptions = horizontal ? yAxisOptions : xAxisOptions;
308
315
  const bandTickValues =
309
- timeAxis && ! dateAxisOptions.tickFormat
316
+ timeAxis && ! dateAxisTickFormat
310
317
  ? getBandTickValues(
311
318
  timeAxis.domain,
312
319
  timeAxis.tickFormatter,
@@ -327,7 +334,7 @@ export function useBarChartOptions(
327
334
  x: {
328
335
  orientation: 'bottom' as const,
329
336
  numTicks: DEFAULT_NUM_TICKS,
330
- tickFormat: xTickFormat,
337
+ tickFormat: xTickFormat || defaultXTickFormat,
331
338
  ...( horizontal ? {} : dateAxisTickValues ),
332
339
  ...( xLabelOverflow === 'ellipsis' ? { tickComponent: TruncatedXTickComponent } : {} ),
333
340
  ...xAxisOptions,
@@ -335,7 +342,7 @@ export function useBarChartOptions(
335
342
  y: {
336
343
  orientation: 'left' as const,
337
344
  numTicks: DEFAULT_NUM_TICKS,
338
- tickFormat: yTickFormat,
345
+ tickFormat: yTickFormat || defaultYTickFormat,
339
346
  ...( horizontal ? dateAxisTickValues : {} ),
340
347
  ...( yLabelOverflow === 'ellipsis' ? { tickComponent: TruncatedYTickComponent } : {} ),
341
348
  ...yAxisOptions,
@@ -345,7 +352,7 @@ export function useBarChartOptions(
345
352
  padding: getGroupPadding( horizontal ? yScale : xScale ),
346
353
  },
347
354
  tooltip: {
348
- labelFormatter: providedToolTipLabelFormatter || defaultTooltipLabelFormatter,
355
+ labelFormatter: dateAxisTickFormat || defaultTooltipLabelFormatter,
349
356
  },
350
357
  };
351
358
  }, [ defaultOptions, axisConfig, stableOptions, horizontal, data ] );
@@ -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
+ } );
@@ -505,6 +505,28 @@ describe( 'BarChart', () => {
505
505
  expect( distinctTexts( ticks ).size ).toBeGreaterThan( 1 );
506
506
  } );
507
507
 
508
+ test( 'keeps the derived date formatter when tickFormat is passed as undefined', () => {
509
+ renderWithTheme( {
510
+ width: 800,
511
+ options: { axis: { x: { tickFormat: undefined } } },
512
+ data: [
513
+ {
514
+ label: 'Series A',
515
+ data: Array.from( { length: 14 }, ( _, i ) => ( {
516
+ date: new Date( 2024, 0, 1 + i ),
517
+ value: 10 + i,
518
+ } ) ),
519
+ options: {},
520
+ },
521
+ ],
522
+ } );
523
+
524
+ const ticks = screen.getAllByText( /^Jan \d+$/ );
525
+ expect( distinctTexts( ticks ).size ).toBeGreaterThan( 1 );
526
+ // A clobbered formatter falls back to raw `Date.toString()` ticks.
527
+ expect( screen.queryByText( /GMT/ ) ).not.toBeInTheDocument();
528
+ } );
529
+
508
530
  test( 'renders distinct hour ticks for sub-daily buckets in a single day', () => {
509
531
  renderWithTheme( {
510
532
  width: 800,
@@ -974,6 +996,85 @@ describe( 'BarChart', () => {
974
996
  } );
975
997
  } );
976
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
+
977
1078
  describe( 'Arrow Key Navigation', () => {
978
1079
  test( 'right arrow key navigates to next data point', async () => {
979
1080
  const user = userEvent.setup();
@@ -3,7 +3,8 @@ import { Group } from '@visx/group';
3
3
  import { createScale, scaleBand } from '@visx/scale';
4
4
  import { Text, type TextProps } from '@visx/text';
5
5
  import { useContext, useMemo } from 'react';
6
- import { GlobalChartsContext, GlobalChartsProvider } from '../../providers';
6
+ import { GlobalChartsContext, GlobalChartsProvider, useGlobalChartsContext } from '../../providers';
7
+ import { isValidHexColor, lightenHexColor } from '../../utils';
7
8
  import { BarChartUnresponsive } from '../bar-chart';
8
9
  import { withResponsive } from '../private/with-responsive';
9
10
  import type { SeriesData } from '../..';
@@ -211,6 +212,14 @@ const getDefaultYOffset = (
211
212
  return -( barThickness + GAP_BETWEEN_BARS );
212
213
  };
213
214
 
215
+ // How far a single-series bar fill travels from white toward the series color.
216
+ //
217
+ // With one series the label is drawn *on* the bar, so the fill is a text background and has to keep
218
+ // contrast with `--a8c-charts-color-label`. At full strength the catalog seed `#3858e9` leaves
219
+ // `#1e1e1e` at 2.97:1; this puts it at 9.0:1. Multi-series bars keep full strength — the label is
220
+ // lifted clear of them, so nothing is read against the fill.
221
+ const BAR_TINT_TOWARD_SERIES = 0.4;
222
+
214
223
  const BarListChartInternal: FC< BarListChartProps > = ( {
215
224
  data,
216
225
  width,
@@ -224,6 +233,35 @@ const BarListChartInternal: FC< BarListChartProps > = ( {
224
233
  },
225
234
  ...rest
226
235
  } ) => {
236
+ const { getElementStyles } = useGlobalChartsContext();
237
+
238
+ // A series carrying its own stroke is left alone — the consumer asked for that exact color.
239
+ const tintedData = useMemo( () => {
240
+ if ( data.length > 1 ) {
241
+ return data;
242
+ }
243
+
244
+ return data.map( ( series, index ) => {
245
+ if ( series.options?.stroke ) {
246
+ return series;
247
+ }
248
+
249
+ const { color } = getElementStyles( { data: series, index } );
250
+
251
+ if ( ! isValidHexColor( color ) ) {
252
+ return series;
253
+ }
254
+
255
+ return {
256
+ ...series,
257
+ options: {
258
+ ...series.options,
259
+ stroke: lightenHexColor( color, 1 - BAR_TINT_TOWARD_SERIES ),
260
+ },
261
+ };
262
+ } );
263
+ }, [ data, getElementStyles ] );
264
+
227
265
  const chartOptions = useMemo( () => {
228
266
  const isMultiSeries = data.length > 1;
229
267
 
@@ -260,7 +298,7 @@ const BarListChartInternal: FC< BarListChartProps > = ( {
260
298
  <BarChartUnresponsive
261
299
  orientation="horizontal"
262
300
  gridVisibility={ 'none' }
263
- data={ data }
301
+ data={ tintedData }
264
302
  width={ width }
265
303
  height={ height }
266
304
  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
  } );
@@ -179,6 +179,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
179
179
  onPointerUp = undefined,
180
180
  onPointerMove = undefined,
181
181
  onPointerOut = undefined,
182
+ onDatumActivate = undefined,
182
183
  zoomable = false,
183
184
  rescaleYOnVisibilityChange = true,
184
185
  defaultHiddenSeries,
@@ -280,7 +281,20 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
280
281
  return min < max ? [ min, max ] : undefined;
281
282
  }, [ rescaleYOnVisibilityChange, dataSorted ] );
282
283
 
283
- // Use the keyboard navigation hook
284
+ // Keyboard navigation steps through x positions, and the grouped tooltip
285
+ // reads every series at that position; the first series names the point.
286
+ const activateSelectedPoint = useCallback(
287
+ ( index: number ) => {
288
+ const series = dataSorted[ 0 ];
289
+ const datum = series?.data[ index ];
290
+
291
+ if ( series && datum ) {
292
+ onDatumActivate?.( { datum, index, key: series.label } );
293
+ }
294
+ },
295
+ [ dataSorted, onDatumActivate ]
296
+ );
297
+
284
298
  const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
285
299
  selectedIndex,
286
300
  setSelectedIndex,
@@ -288,11 +302,12 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
288
302
  setIsNavigating,
289
303
  chartRef,
290
304
  totalPoints: dataSorted[ 0 ]?.data.length || 0,
305
+ onActivate: activateSelectedPoint,
291
306
  } );
292
307
 
293
308
  const chartOptions = useMemo( () => {
294
- const { tickResolution, ...xAxisOptions } = options?.axis?.x ?? {};
295
- const formatter = xAxisOptions.tickFormat || getFormatter( dataSorted, tickResolution );
309
+ const { tickResolution, tickFormat, ...xAxisOptions } = options?.axis?.x ?? {};
310
+ const formatter = tickFormat || getFormatter( dataSorted, tickResolution );
296
311
 
297
312
  return {
298
313
  axis: {