@automattic/charts 3.1.1 → 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 (38) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/TOKENS.md +51 -12
  3. package/dist/index.cjs +292 -200
  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 +291 -199
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -8
  11. package/src/charts/bar-chart/bar-chart.tsx +36 -10
  12. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +67 -0
  13. package/src/charts/bar-chart/test/bar-chart.test.tsx +79 -0
  14. package/src/charts/bar-list-chart/bar-list-chart.tsx +40 -2
  15. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +38 -0
  16. package/src/charts/line-chart/line-chart.tsx +16 -1
  17. package/src/charts/line-chart/test/line-chart.test.tsx +47 -0
  18. package/src/charts/sparkline/sparkline.tsx +9 -3
  19. package/src/charts/sparkline/types.ts +1 -1
  20. package/src/components/tooltip/accessible-tooltip.tsx +32 -2
  21. package/src/components/tooltip/test/accessible-tooltip.test.tsx +68 -0
  22. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +4 -4
  23. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +17 -10
  24. package/src/hooks/test/use-xychart-theme.test.tsx +50 -5
  25. package/src/hooks/use-xychart-theme.ts +14 -20
  26. package/src/providers/chart-context/global-charts-provider.tsx +44 -33
  27. package/src/providers/chart-context/private/series-palette.ts +45 -0
  28. package/src/providers/chart-context/private/theme-override-vars.ts +46 -2
  29. package/src/providers/chart-context/private/with-catalog-pointers.ts +45 -2
  30. package/src/providers/chart-context/test/sparse-theme.test.ts +16 -2
  31. package/src/providers/chart-context/test/theme-override-vars.test.ts +65 -0
  32. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +8 -1
  33. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +57 -0
  34. package/src/providers/chart-context/themes.ts +8 -3
  35. package/src/styles/chart-scope.scss +52 -24
  36. package/src/styles/test/chart-scope.test.ts +22 -9
  37. package/src/types.ts +148 -31
  38. 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.1",
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": {
@@ -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 && (
@@ -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();
@@ -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,6 +302,7 @@ 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( () => {
@@ -1153,6 +1153,53 @@ describe( 'LineChart', () => {
1153
1153
  } );
1154
1154
  } );
1155
1155
 
1156
+ describe( 'Activation', () => {
1157
+ const SERIES_A = {
1158
+ label: 'Series A',
1159
+ data: [
1160
+ { date: new Date( '2024-01-01' ), value: 10 },
1161
+ { date: new Date( '2024-01-02' ), value: 20 },
1162
+ ],
1163
+ options: {},
1164
+ };
1165
+ const SERIES_B = {
1166
+ label: 'Series B',
1167
+ data: [
1168
+ { date: new Date( '2024-01-01' ), value: 15 },
1169
+ { date: new Date( '2024-01-02' ), value: 25 },
1170
+ ],
1171
+ options: {},
1172
+ };
1173
+
1174
+ // Navigation steps through x positions; the first series names the point.
1175
+ test( 'Enter hands the selected point to onDatumActivate', async () => {
1176
+ const user = userEvent.setup();
1177
+ const onDatumActivate = jest.fn();
1178
+ renderWithTheme( { data: [ SERIES_A, SERIES_B ], onDatumActivate } );
1179
+
1180
+ screen.getByRole( 'grid', { name: /line chart/i } ).focus();
1181
+ await user.keyboard( '{ArrowRight}{ArrowRight}{Enter}' );
1182
+
1183
+ expect( onDatumActivate ).toHaveBeenCalledTimes( 1 );
1184
+ expect( onDatumActivate ).toHaveBeenCalledWith( {
1185
+ datum: SERIES_A.data[ 1 ],
1186
+ index: 1,
1187
+ key: 'Series A',
1188
+ } );
1189
+ } );
1190
+
1191
+ test( 'Enter with no point selected activates nothing', async () => {
1192
+ const user = userEvent.setup();
1193
+ const onDatumActivate = jest.fn();
1194
+ renderWithTheme( { data: [ SERIES_A ], onDatumActivate } );
1195
+
1196
+ screen.getByRole( 'grid', { name: /line chart/i } ).focus();
1197
+ await user.keyboard( '{Enter}' );
1198
+
1199
+ expect( onDatumActivate ).not.toHaveBeenCalled();
1200
+ } );
1201
+ } );
1202
+
1156
1203
  describe( 'Arrow Key Navigation', () => {
1157
1204
  test( 'right arrow key navigates to next data point', async () => {
1158
1205
  const user = userEvent.setup();
@@ -1,6 +1,6 @@
1
1
  import clsx from 'clsx';
2
- import { useMemo, forwardRef } from 'react';
3
- import { useGlobalChartsTheme } from '../../providers';
2
+ import { useContext, useMemo, forwardRef } from 'react';
3
+ import { GlobalChartsContext, useGlobalChartsTheme } from '../../providers';
4
4
  import { LineChartUnresponsive } from '../line-chart';
5
5
  import { withResponsive } from '../private/with-responsive';
6
6
  import styles from './sparkline.module.scss';
@@ -60,6 +60,9 @@ const SparklineComponent = forwardRef< HTMLDivElement, SparklineProps >(
60
60
  ref
61
61
  ) => {
62
62
  const theme = useGlobalChartsTheme();
63
+ // Read through the context rather than `useGlobalChartsContext()`, which throws: Sparkline
64
+ // renders standalone too, and only the LineChart it delegates to supplies a provider.
65
+ const chartsContext = useContext( GlobalChartsContext );
63
66
 
64
67
  // Get theme defaults for sparkline
65
68
  const themeStrokeWidth = theme.sparkline?.strokeWidth ?? 1.5;
@@ -127,7 +130,10 @@ const SparklineComponent = forwardRef< HTMLDivElement, SparklineProps >(
127
130
  if ( data.length === 1 ) {
128
131
  const cx = width / 2;
129
132
  const cy = height / 2;
130
- const resolvedColor = color || '#000000';
133
+ // The multi-point path lets LineChart resolve the palette; a lone circle has to ask for
134
+ // the same slot itself, or omitting `color` paints black instead of the series color.
135
+ const resolvedColor =
136
+ color || chartsContext?.getElementStyles( { index: 0 } ).color || '#000000';
131
137
 
132
138
  return (
133
139
  <div
@@ -47,7 +47,7 @@ export interface SparklineProps {
47
47
 
48
48
  /**
49
49
  * Color for the line stroke (hex or CSS color)
50
- * @default Theme color (first color in theme.colors array)
50
+ * @default The first series palette color (`--a8c-charts-color-series-1`)
51
51
  */
52
52
  color?: string;
53
53
 
@@ -1,7 +1,10 @@
1
1
  import { Tooltip, TooltipContext } from '@visx/xychart';
2
2
  import clsx from 'clsx';
3
3
  import { useContext, useEffect, useCallback, useMemo, useRef } from 'react';
4
+ import { useGlobalChartsTheme } from '../../providers';
5
+ import { useChartScopeElement } from '../../providers/chart-scope';
4
6
  import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
7
+ import { resolveCssVariable } from '../../utils';
5
8
  import type { SeriesData, DataPointDate } from '../../types';
6
9
  import type { RenderTooltipParams, XyChartTooltipProps } from '../../visx/types';
7
10
  import type { ReactNode } from 'react';
@@ -45,9 +48,21 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
45
48
  keyboardFocusedClassName,
46
49
  series = [],
47
50
  mode = 'group',
51
+ verticalCrosshairStyle,
52
+ horizontalCrosshairStyle,
48
53
  ...props
49
54
  } ) => {
50
55
  const tooltipContext = useContext( TooltipContext );
56
+ const scopeElement = useChartScopeElement();
57
+ const gridStroke = useGlobalChartsTheme().gridStyles?.stroke;
58
+
59
+ // The crosshair is painted in a portal outside the scope, so it needs a resolved color; see TOKENS.md#the-svg-bridge.
60
+ const crosshairStroke = useMemo( () => {
61
+ const stroke = gridStroke ? resolveCssVariable( gridStroke, scopeElement ) : null;
62
+
63
+ // Passing `stroke: undefined` would erase the crosshair: it overrides visx's own value, and SVG's initial `stroke` is `none`.
64
+ return stroke ? { stroke } : undefined;
65
+ }, [ gridStroke, scopeElement ] );
51
66
 
52
67
  const tooltipData = useMemo( () => {
53
68
  if ( mode !== 'individual' ) return [];
@@ -158,7 +173,14 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
158
173
  };
159
174
  }, [ renderTooltip, selectedIndex, tooltipRef, keyboardFocusedClassName ] );
160
175
 
161
- return <Tooltip { ...props } renderTooltip={ focusableRenderTooltip } />;
176
+ return (
177
+ <Tooltip
178
+ { ...props }
179
+ verticalCrosshairStyle={ { ...crosshairStroke, ...verticalCrosshairStyle } }
180
+ horizontalCrosshairStyle={ { ...crosshairStroke, ...horizontalCrosshairStyle } }
181
+ renderTooltip={ focusableRenderTooltip }
182
+ />
183
+ );
162
184
  };
163
185
 
164
186
  // Keyboard navigation hook for charts
@@ -172,6 +194,11 @@ interface UseKeyboardNavigationProps {
172
194
  * Total number of navigation points (length of tooltip data array)
173
195
  */
174
196
  totalPoints: number;
197
+ /**
198
+ * Called with the selected index on Enter or Space, so a chart can treat the
199
+ * keyboard selection the way it treats a click.
200
+ */
201
+ onActivate?: ( index: number ) => void;
175
202
  }
176
203
 
177
204
  export const useKeyboardNavigation = ( {
@@ -181,6 +208,7 @@ export const useKeyboardNavigation = ( {
181
208
  setIsNavigating,
182
209
  chartRef,
183
210
  totalPoints,
211
+ onActivate,
184
212
  }: UseKeyboardNavigationProps ) => {
185
213
  // Focus the tooltip as soon as it is rendered
186
214
  const tooltipRef = useCallback(
@@ -237,9 +265,11 @@ export const useKeyboardNavigation = ( {
237
265
  setSelectedIndex( undefined );
238
266
  setIsNavigating( false );
239
267
  chartRef.current?.focus();
268
+ } else if ( ( event.key === 'Enter' || event.key === ' ' ) && selectedIndex !== undefined ) {
269
+ onActivate?.( selectedIndex );
240
270
  }
241
271
  },
242
- [ totalPoints, selectedIndex, setSelectedIndex, setIsNavigating, chartRef ]
272
+ [ totalPoints, selectedIndex, setSelectedIndex, setIsNavigating, chartRef, onActivate ]
243
273
  );
244
274
 
245
275
  return {
@@ -0,0 +1,68 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { LineChartUnresponsive } from '../../../charts/line-chart/line-chart';
4
+ import { GlobalChartsProvider } from '../../../providers';
5
+ import { ChartScopeContext } from '../../../providers/chart-scope';
6
+ import type { ReactNode } from 'react';
7
+
8
+ // A real chart is the harness rather than the subject: the crosshairs render only once visx has a data context and an open tooltip. The unresponsive export is what lets the test own the scope element — `withResponsive` otherwise provides its own wrapper as the scope.
9
+ const renderChart = ( scope?: HTMLElement ) => {
10
+ const chart = (
11
+ <LineChartUnresponsive
12
+ width={ 500 }
13
+ height={ 300 }
14
+ data={ [
15
+ {
16
+ label: 'Series A',
17
+ data: [
18
+ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
19
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
20
+ ],
21
+ options: {},
22
+ },
23
+ ] }
24
+ withTooltips
25
+ withTooltipCrosshairs={ { showVertical: true } }
26
+ withGradientFill={ false }
27
+ />
28
+ );
29
+
30
+ const scoped: ReactNode = scope ? (
31
+ <ChartScopeContext.Provider value={ scope }>{ chart }</ChartScopeContext.Provider>
32
+ ) : (
33
+ chart
34
+ );
35
+
36
+ return render( <GlobalChartsProvider>{ scoped }</GlobalChartsProvider> );
37
+ };
38
+
39
+ const openTooltip = async () => {
40
+ const user = userEvent.setup();
41
+
42
+ screen.getByRole( 'grid', { name: /line chart/i } ).focus();
43
+ await user.keyboard( '{ArrowRight}' );
44
+
45
+ // eslint-disable-next-line testing-library/no-node-access -- visx owns the crosshair and hardcodes its class name, so there is no attribute to reach it by.
46
+ return document.querySelector( '.visx-crosshair-vertical line' );
47
+ };
48
+
49
+ describe( 'AccessibleTooltip', () => {
50
+ // The crosshair is painted in a portal outside the scope element; see TOKENS.md#the-svg-bridge.
51
+ it( 'reads the grid role from the scope element', async () => {
52
+ const scope = document.createElement( 'div' );
53
+ scope.style.setProperty( '--a8c-charts-color-grid', 'rgb(1, 2, 3)' );
54
+ document.body.appendChild( scope );
55
+
56
+ renderChart( scope );
57
+
58
+ await expect( openTooltip() ).resolves.toHaveAttribute( 'stroke', 'rgb(1, 2, 3)' );
59
+
60
+ document.body.removeChild( scope );
61
+ } );
62
+
63
+ it( 'falls back to the catalog default when the role is unset', async () => {
64
+ renderChart();
65
+
66
+ await expect( openTooltip() ).resolves.toHaveAttribute( 'stroke', '#dbdbdb' );
67
+ } );
68
+ } );