@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.
- package/CHANGELOG.md +12 -0
- package/TOKENS.md +51 -12
- package/dist/index.cjs +292 -200
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +7 -1
- package/dist/index.d.cts +223 -127
- package/dist/index.d.ts +223 -127
- package/dist/index.js +291 -199
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/charts/bar-chart/bar-chart.tsx +36 -10
- package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +67 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +79 -0
- package/src/charts/bar-list-chart/bar-list-chart.tsx +40 -2
- package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +38 -0
- package/src/charts/line-chart/line-chart.tsx +16 -1
- package/src/charts/line-chart/test/line-chart.test.tsx +47 -0
- package/src/charts/sparkline/sparkline.tsx +9 -3
- package/src/charts/sparkline/types.ts +1 -1
- package/src/components/tooltip/accessible-tooltip.tsx +32 -2
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +68 -0
- package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +4 -4
- package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +17 -10
- package/src/hooks/test/use-xychart-theme.test.tsx +50 -5
- package/src/hooks/use-xychart-theme.ts +14 -20
- package/src/providers/chart-context/global-charts-provider.tsx +44 -33
- package/src/providers/chart-context/private/series-palette.ts +45 -0
- package/src/providers/chart-context/private/theme-override-vars.ts +46 -2
- package/src/providers/chart-context/private/with-catalog-pointers.ts +45 -2
- package/src/providers/chart-context/test/sparse-theme.test.ts +16 -2
- package/src/providers/chart-context/test/theme-override-vars.test.ts +65 -0
- package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +8 -1
- package/src/providers/chart-context/test/with-catalog-pointers.test.ts +57 -0
- package/src/providers/chart-context/themes.ts +8 -3
- package/src/styles/chart-scope.scss +52 -24
- package/src/styles/test/chart-scope.test.ts +22 -9
- package/src/types.ts +148 -31
- 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.
|
|
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.
|
|
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.
|
|
88
|
-
"@wordpress/ui": "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.
|
|
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.
|
|
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.
|
|
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.
|
|
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={
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
} );
|