@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.
- package/CHANGELOG.md +17 -0
- package/TOKENS.md +51 -12
- package/dist/index.cjs +307 -213
- 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 +306 -212
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/charts/area-chart/area-chart.tsx +2 -2
- package/src/charts/area-chart/test/area-chart.test.tsx +45 -0
- package/src/charts/bar-chart/bar-chart.tsx +36 -10
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +19 -12
- package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +67 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +101 -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 +18 -3
- package/src/charts/line-chart/test/line-chart.test.tsx +98 -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": {
|
|
@@ -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 =
|
|
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 —
|
|
158
|
-
//
|
|
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 } =
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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 && !
|
|
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:
|
|
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={
|
|
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,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 =
|
|
309
|
+
const { tickResolution, tickFormat, ...xAxisOptions } = options?.axis?.x ?? {};
|
|
310
|
+
const formatter = tickFormat || getFormatter( dataSorted, tickResolution );
|
|
296
311
|
|
|
297
312
|
return {
|
|
298
313
|
axis: {
|