@automattic/charts 4.2.0 → 4.4.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 +28 -0
- package/dist/index.cjs +533 -127
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -2
- package/dist/index.d.ts +27 -2
- package/dist/index.js +535 -129
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.tsx +42 -10
- package/src/charts/area-chart/private/validate-data.ts +2 -3
- package/src/charts/area-chart/test/area-chart.test.tsx +72 -1
- package/src/charts/bar-chart/bar-chart.tsx +135 -96
- package/src/charts/bar-chart/index.ts +1 -1
- package/src/charts/bar-chart/private/band-highlight.tsx +3 -1
- package/src/charts/bar-chart/private/band-tooltip.tsx +1 -1
- package/src/charts/bar-chart/private/classified-bar-series.tsx +85 -0
- package/src/charts/bar-chart/private/comparison-bars-geometry.ts +3 -6
- package/src/charts/bar-chart/private/index.ts +2 -0
- package/src/charts/bar-chart/private/test/band-highlight.test.tsx +28 -2
- package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +2 -3
- package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +12 -39
- package/src/charts/bar-chart/private/value-domain.ts +90 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +614 -10
- package/src/charts/bar-chart/types.ts +43 -0
- package/src/charts/line-chart/line-chart.tsx +148 -35
- package/src/charts/line-chart/private/index.ts +1 -0
- package/src/charts/line-chart/private/line-chart-glyph.tsx +17 -3
- package/src/charts/line-chart/private/nearest-pointer-events.tsx +155 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +604 -9
- package/src/charts/line-chart/types.ts +2 -1
- package/src/charts/private/readings.ts +34 -0
- package/src/charts/private/test/readings.test.ts +29 -0
- package/src/charts/private/test/whole-number-ticks.test.tsx +57 -0
- package/src/charts/private/whole-number-ticks.tsx +79 -0
- package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +124 -0
- package/src/components/legend/hooks/use-chart-legend-items.ts +60 -2
- package/src/components/legend/private/base-legend.tsx +36 -21
- package/src/components/legend/test/legend.test.tsx +84 -0
- package/src/components/legend/types.ts +2 -0
- package/src/components/legend/utils/index.ts +0 -1
- package/src/components/tooltip/accessible-tooltip.tsx +1 -1
- package/src/hooks/test/use-chart-margin.test.tsx +35 -0
- package/src/hooks/use-chart-margin.tsx +7 -2
- package/src/providers/chart-context/global-charts-provider.tsx +1 -1
- package/src/providers/chart-context/test/chart-context.test.tsx +4 -4
- package/src/types.ts +8 -0
- package/src/components/legend/utils/label-transform-factory.ts +0 -24
|
@@ -10,10 +10,11 @@ import {
|
|
|
10
10
|
import userEvent from '@testing-library/user-event';
|
|
11
11
|
import { GlobalChartsProvider } from '../../../providers';
|
|
12
12
|
import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context';
|
|
13
|
+
import { steadyTrafficData } from '../../../stories/sample-data';
|
|
13
14
|
import BarChart, { BarChartUnresponsive } from '../bar-chart';
|
|
14
15
|
import { useBarChartOptions } from '../private';
|
|
15
16
|
import type { GlobalChartsContextValue } from '../../../providers/chart-context/types';
|
|
16
|
-
import type { SeriesData } from '../../../types';
|
|
17
|
+
import type { DataPointDate, SeriesData } from '../../../types';
|
|
17
18
|
|
|
18
19
|
// Mock useElementSize to return non-zero dimensions in jsdom so charts render
|
|
19
20
|
const mockRefCallback = jest.fn();
|
|
@@ -44,6 +45,11 @@ describe( 'BarChart', () => {
|
|
|
44
45
|
return screen.getByRole( 'grid' ).querySelectorAll( '.visx-bar-group rect' );
|
|
45
46
|
};
|
|
46
47
|
|
|
48
|
+
const getGridLines = () => {
|
|
49
|
+
// eslint-disable-next-line testing-library/no-node-access -- visx grid lines cannot receive test IDs.
|
|
50
|
+
return screen.getByRole( 'grid' ).querySelectorAll( '.visx-rows line' );
|
|
51
|
+
};
|
|
52
|
+
|
|
47
53
|
const renderWithTheme = ( props = {}, children = undefined ) => {
|
|
48
54
|
return render(
|
|
49
55
|
<GlobalChartsProvider>
|
|
@@ -54,6 +60,70 @@ describe( 'BarChart', () => {
|
|
|
54
60
|
);
|
|
55
61
|
};
|
|
56
62
|
|
|
63
|
+
test.each(
|
|
64
|
+
[ 'vertical', 'horizontal' ].flatMap( orientation =>
|
|
65
|
+
[
|
|
66
|
+
[ 10, 20, 30 ],
|
|
67
|
+
[ -20, -10, -30 ],
|
|
68
|
+
[ -20, 10, 30 ],
|
|
69
|
+
].map( values => ( { orientation, values } ) )
|
|
70
|
+
)
|
|
71
|
+
)(
|
|
72
|
+
'adds datum classes without changing $orientation bar attributes for $values',
|
|
73
|
+
async ( { orientation, values } ) => {
|
|
74
|
+
const series = {
|
|
75
|
+
...defaultProps.data[ 0 ],
|
|
76
|
+
data: defaultProps.data[ 0 ].data.map( ( datum, index ) => ( {
|
|
77
|
+
...datum,
|
|
78
|
+
value: values[ index ],
|
|
79
|
+
} ) ),
|
|
80
|
+
};
|
|
81
|
+
const props = {
|
|
82
|
+
orientation,
|
|
83
|
+
data: [ series, { ...series, label: 'Series B' } ],
|
|
84
|
+
};
|
|
85
|
+
const view = renderWithTheme( props );
|
|
86
|
+
await waitFor( () => expect( getBarRects() ).toHaveLength( 6 ) );
|
|
87
|
+
const attributes = () =>
|
|
88
|
+
Array.from( getBarRects(), bar =>
|
|
89
|
+
[ 'x', 'y', 'width', 'height', 'fill', 'tabindex' ].map( attribute =>
|
|
90
|
+
bar.getAttribute( attribute )
|
|
91
|
+
)
|
|
92
|
+
);
|
|
93
|
+
const original = attributes();
|
|
94
|
+
view.unmount();
|
|
95
|
+
renderWithTheme( {
|
|
96
|
+
...props,
|
|
97
|
+
barClassName: ( datum: DataPointDate ) =>
|
|
98
|
+
datum.value === values[ 0 ] ? 'first-value' : undefined,
|
|
99
|
+
} );
|
|
100
|
+
await waitFor( () => expect( getBarRects() ).toHaveLength( 6 ) );
|
|
101
|
+
expect( attributes() ).toEqual( original );
|
|
102
|
+
expect( getBarRects()[ 0 ] ).toHaveClass( 'visx-bar', 'first-value' );
|
|
103
|
+
expect( getBarRects()[ 1 ] ).not.toHaveClass( 'first-value' );
|
|
104
|
+
expect( getBarRects()[ 3 ] ).toHaveClass( 'first-value' );
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
test( 'applies tooltip style overrides while keeping keyboard tooltip content', async () => {
|
|
109
|
+
const user = userEvent.setup();
|
|
110
|
+
renderWithTheme( {
|
|
111
|
+
withTooltips: true,
|
|
112
|
+
barClassName: () => 'styled-bar',
|
|
113
|
+
tooltipStyle: { padding: 0, backgroundColor: 'transparent', boxShadow: 'none' },
|
|
114
|
+
} );
|
|
115
|
+
await user.tab();
|
|
116
|
+
await user.keyboard( '{ArrowRight}' );
|
|
117
|
+
const tooltip = await screen.findByRole( 'tooltip' );
|
|
118
|
+
expect( tooltip ).toHaveTextContent( 'Jan 1' );
|
|
119
|
+
expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( {
|
|
120
|
+
padding: '0px',
|
|
121
|
+
// jsdom normalizes transparent in computed styles.
|
|
122
|
+
'background-color': 'rgba(0, 0, 0, 0)',
|
|
123
|
+
'box-shadow': 'none',
|
|
124
|
+
} );
|
|
125
|
+
} );
|
|
126
|
+
|
|
57
127
|
describe( 'pointer selection and focus return', () => {
|
|
58
128
|
const screenTransform = Object.getOwnPropertyDescriptor( SVGElement.prototype, 'getScreenCTM' );
|
|
59
129
|
beforeAll( () => {
|
|
@@ -134,7 +204,7 @@ describe( 'BarChart', () => {
|
|
|
134
204
|
expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
|
|
135
205
|
} );
|
|
136
206
|
|
|
137
|
-
test( '
|
|
207
|
+
test( 'keeps band correction after BarGroup subscriptions on mount and rerender', async () => {
|
|
138
208
|
const chart = ( className: string ) => (
|
|
139
209
|
<GlobalChartsProvider>
|
|
140
210
|
<BarChart { ...defaultProps } withTooltips className={ className } />
|
|
@@ -152,6 +222,63 @@ describe( 'BarChart', () => {
|
|
|
152
222
|
await waitFor( () => expect( screen.getByRole( 'tooltip' ) ).toHaveTextContent( 'Jan 1' ) );
|
|
153
223
|
} );
|
|
154
224
|
|
|
225
|
+
test( 'clears the highlight callback when the pointer leaves the plot', async () => {
|
|
226
|
+
const onBandHighlightChange = jest.fn();
|
|
227
|
+
renderWithTheme( { withTooltips: true, withBandHighlight: true, onBandHighlightChange } );
|
|
228
|
+
const [ [ x, , width ] ] = barGeometry();
|
|
229
|
+
hover( x + width / 2, 150 );
|
|
230
|
+
await expect(
|
|
231
|
+
screen.findByTestId( 'bar-chart-band-highlight' )
|
|
232
|
+
).resolves.toBeInTheDocument();
|
|
233
|
+
pointer( 'pointerout', x + width / 2, 150 );
|
|
234
|
+
await waitFor( () => expect( onBandHighlightChange ).toHaveBeenLastCalledWith( null ) );
|
|
235
|
+
expect( screen.queryByTestId( 'bar-chart-band-highlight' ) ).not.toBeInTheDocument();
|
|
236
|
+
} );
|
|
237
|
+
|
|
238
|
+
test.each( [ 'comparison', 'hidden' ] )(
|
|
239
|
+
'selects the visible primary band with a %s series',
|
|
240
|
+
async mode => {
|
|
241
|
+
const onBandHighlightChange = jest.fn();
|
|
242
|
+
const onPointerUp = jest.fn();
|
|
243
|
+
renderWithTheme( {
|
|
244
|
+
withTooltips: true,
|
|
245
|
+
withBandHighlight: true,
|
|
246
|
+
onBandHighlightChange,
|
|
247
|
+
onPointerUp,
|
|
248
|
+
defaultHiddenSeries: mode === 'hidden' ? [ 'Other' ] : [],
|
|
249
|
+
data: [
|
|
250
|
+
{ ...defaultProps.data[ 0 ], group: 'scores' },
|
|
251
|
+
{
|
|
252
|
+
label: 'Other',
|
|
253
|
+
group: 'scores',
|
|
254
|
+
options: mode === 'comparison' ? { type: 'comparison' } : {},
|
|
255
|
+
data: defaultProps.data[ 0 ].data,
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
} );
|
|
259
|
+
const [ [ x, , width ] ] = barGeometry();
|
|
260
|
+
hover( x + width / 2, 150 );
|
|
261
|
+
const highlight = await screen.findByTestId( 'bar-chart-band-highlight' );
|
|
262
|
+
const otherSlot =
|
|
263
|
+
Number( highlight.getAttribute( 'x' ) ) +
|
|
264
|
+
Number( highlight.getAttribute( 'width' ) ) * 0.85;
|
|
265
|
+
hover( otherSlot, 150 );
|
|
266
|
+
pointer( 'pointerup', otherSlot, 150 );
|
|
267
|
+
for ( const callback of [ onBandHighlightChange, onPointerUp ] ) {
|
|
268
|
+
expect( callback ).not.toHaveBeenCalledWith(
|
|
269
|
+
expect.objectContaining( { key: 'Other' } )
|
|
270
|
+
);
|
|
271
|
+
expect( callback ).toHaveBeenLastCalledWith(
|
|
272
|
+
expect.objectContaining( {
|
|
273
|
+
key: 'Series A',
|
|
274
|
+
index: 0,
|
|
275
|
+
datum: defaultProps.data[ 0 ].data[ 0 ],
|
|
276
|
+
} )
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
);
|
|
281
|
+
|
|
155
282
|
test( 'reports the tooltip datum when pressing and releasing in a padded gap', async () => {
|
|
156
283
|
const onPointerDown = jest.fn();
|
|
157
284
|
const onPointerUp = jest.fn();
|
|
@@ -224,7 +351,36 @@ describe( 'BarChart', () => {
|
|
|
224
351
|
);
|
|
225
352
|
} );
|
|
226
353
|
|
|
227
|
-
test( '
|
|
354
|
+
test( 'warns once when highlight options lack tooltips across callback changes and remounts', () => {
|
|
355
|
+
const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
|
|
356
|
+
try {
|
|
357
|
+
const onBandHighlightChange = jest.fn();
|
|
358
|
+
const { rerender, unmount } = renderWithTheme( {
|
|
359
|
+
withBandHighlight: true,
|
|
360
|
+
onBandHighlightChange,
|
|
361
|
+
} );
|
|
362
|
+
for ( let i = 0; i < 4; i++ ) {
|
|
363
|
+
rerender(
|
|
364
|
+
<GlobalChartsProvider>
|
|
365
|
+
<BarChart { ...defaultProps } withBandHighlight onBandHighlightChange={ jest.fn() } />
|
|
366
|
+
</GlobalChartsProvider>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
unmount();
|
|
370
|
+
renderWithTheme( { withBandHighlight: true, onBandHighlightChange } );
|
|
371
|
+
expect( warn ).toHaveBeenCalledTimes( 1 );
|
|
372
|
+
expect( screen.getByRole( 'grid' ) ).toBeInTheDocument();
|
|
373
|
+
expect( warn ).toHaveBeenCalledWith(
|
|
374
|
+
'[Charts] BarChart: withBandHighlight and onBandHighlightChange require withTooltips.'
|
|
375
|
+
);
|
|
376
|
+
expect( onBandHighlightChange ).not.toHaveBeenCalled();
|
|
377
|
+
expect( screen.queryByTestId( 'bar-chart-band-highlight' ) ).not.toBeInTheDocument();
|
|
378
|
+
} finally {
|
|
379
|
+
warn.mockRestore();
|
|
380
|
+
}
|
|
381
|
+
} );
|
|
382
|
+
|
|
383
|
+
test( 'reports band bounds and point indexes during keyboard navigation and clears them on Escape', async () => {
|
|
228
384
|
const user = userEvent.setup();
|
|
229
385
|
const onBandHighlightChange = jest.fn();
|
|
230
386
|
renderWithTheme( { withTooltips: true, onBandHighlightChange } );
|
|
@@ -233,6 +389,8 @@ describe( 'BarChart', () => {
|
|
|
233
389
|
expect( onBandHighlightChange ).toHaveBeenLastCalledWith(
|
|
234
390
|
expect.objectContaining( {
|
|
235
391
|
datum: expect.objectContaining( { value: 10 } ),
|
|
392
|
+
key: 'Series A',
|
|
393
|
+
index: 0,
|
|
236
394
|
x: expect.any( Number ),
|
|
237
395
|
y: expect.any( Number ),
|
|
238
396
|
width: expect.any( Number ),
|
|
@@ -240,6 +398,14 @@ describe( 'BarChart', () => {
|
|
|
240
398
|
} )
|
|
241
399
|
);
|
|
242
400
|
expect( screen.queryByTestId( 'bar-chart-band-highlight' ) ).not.toBeInTheDocument();
|
|
401
|
+
await user.keyboard( '{ArrowRight}' );
|
|
402
|
+
expect( onBandHighlightChange ).toHaveBeenLastCalledWith(
|
|
403
|
+
expect.objectContaining( {
|
|
404
|
+
datum: expect.objectContaining( { value: 20 } ),
|
|
405
|
+
key: 'Series A',
|
|
406
|
+
index: 1,
|
|
407
|
+
} )
|
|
408
|
+
);
|
|
243
409
|
await user.keyboard( '{Escape}' );
|
|
244
410
|
await waitFor( () => expect( onBandHighlightChange ).toHaveBeenLastCalledWith( null ) );
|
|
245
411
|
} );
|
|
@@ -282,19 +448,126 @@ describe( 'BarChart', () => {
|
|
|
282
448
|
);
|
|
283
449
|
|
|
284
450
|
test( 'aligns horizontal grid lines with explicit value ticks', () => {
|
|
285
|
-
|
|
451
|
+
renderWithTheme( {
|
|
286
452
|
gridVisibility: 'x',
|
|
287
453
|
options: {
|
|
288
454
|
axis: { y: { tickValues: [ 0, 50, 100 ] } },
|
|
289
455
|
scale: { y: { domain: [ 0, 100 ] } },
|
|
290
456
|
},
|
|
291
457
|
} );
|
|
292
|
-
|
|
293
|
-
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
|
294
|
-
const lines = container.querySelectorAll( '.visx-rows line' );
|
|
458
|
+
const lines = getGridLines();
|
|
295
459
|
expect( lines ).toHaveLength( 3 );
|
|
296
460
|
} );
|
|
297
461
|
|
|
462
|
+
describe( 'Whole-number value ticks', () => {
|
|
463
|
+
const wholeNumberData: SeriesData[] = [
|
|
464
|
+
{
|
|
465
|
+
label: 'Series A',
|
|
466
|
+
data: [
|
|
467
|
+
{ label: 'Mon', value: 0 },
|
|
468
|
+
{ label: 'Tue', value: 1 },
|
|
469
|
+
{ label: 'Wed', value: 1 },
|
|
470
|
+
],
|
|
471
|
+
},
|
|
472
|
+
];
|
|
473
|
+
|
|
474
|
+
test( 'labels a whole-number range smaller than the tick count once per whole number', () => {
|
|
475
|
+
renderWithTheme( { data: wholeNumberData } );
|
|
476
|
+
const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
|
|
477
|
+
const ticks = within( chart )
|
|
478
|
+
.getAllByText( /^-?[\d.,]+$/ )
|
|
479
|
+
.map( el => el.textContent );
|
|
480
|
+
expect( ticks.sort() ).toEqual( [ '0', '1' ] );
|
|
481
|
+
} );
|
|
482
|
+
|
|
483
|
+
test( 'labels a horizontal whole-number range once per whole number', () => {
|
|
484
|
+
renderWithTheme( { data: wholeNumberData, orientation: 'horizontal' } );
|
|
485
|
+
const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
|
|
486
|
+
const ticks = within( chart )
|
|
487
|
+
.getAllByText( /^-?[\d.,]+$/ )
|
|
488
|
+
.map( el => el.textContent );
|
|
489
|
+
expect( ticks.sort() ).toEqual( [ '0', '1' ] );
|
|
490
|
+
} );
|
|
491
|
+
|
|
492
|
+
test( 'draws a flat series of ones on a 0 and 1 axis', () => {
|
|
493
|
+
renderWithTheme( {
|
|
494
|
+
data: [
|
|
495
|
+
{
|
|
496
|
+
label: 'Series A',
|
|
497
|
+
data: [
|
|
498
|
+
{ label: 'Mon', value: 1 },
|
|
499
|
+
{ label: 'Tue', value: 1 },
|
|
500
|
+
],
|
|
501
|
+
},
|
|
502
|
+
],
|
|
503
|
+
} );
|
|
504
|
+
const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
|
|
505
|
+
const ticks = within( chart )
|
|
506
|
+
.getAllByText( /^-?[\d.,]+$/ )
|
|
507
|
+
.map( el => el.textContent );
|
|
508
|
+
expect( ticks.sort() ).toEqual( [ '0', '1' ] );
|
|
509
|
+
} );
|
|
510
|
+
|
|
511
|
+
test.each( [ 'vertical', 'horizontal' ] as const )(
|
|
512
|
+
'keeps every tick on a value domain the caller pinned (%s)',
|
|
513
|
+
orientation => {
|
|
514
|
+
const valueAxis = orientation === 'horizontal' ? 'x' : 'y';
|
|
515
|
+
renderWithTheme( {
|
|
516
|
+
orientation,
|
|
517
|
+
data: [
|
|
518
|
+
{
|
|
519
|
+
label: 'Series A',
|
|
520
|
+
data: [
|
|
521
|
+
{ label: 'Mon', value: 0 },
|
|
522
|
+
{ label: 'Tue', value: 0 },
|
|
523
|
+
],
|
|
524
|
+
},
|
|
525
|
+
],
|
|
526
|
+
options: {
|
|
527
|
+
[ `${ valueAxis }Scale` ]: { domain: [ 0, 1 ] },
|
|
528
|
+
axis: {
|
|
529
|
+
[ valueAxis ]: { tickFormat: ( value: number ) => `${ Math.round( value * 100 ) }%` },
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
} );
|
|
533
|
+
const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
|
|
534
|
+
expect( within( chart ).getAllByText( /^\d+%$/ ) ).toHaveLength( 6 );
|
|
535
|
+
}
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
test( "keeps a caller's value tickValues", () => {
|
|
539
|
+
renderWithTheme( {
|
|
540
|
+
data: wholeNumberData,
|
|
541
|
+
options: { axis: { y: { tickValues: [ 0, 0.5, 1 ] } } },
|
|
542
|
+
} );
|
|
543
|
+
const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
|
|
544
|
+
expect( within( chart ).getAllByText( /^-?[\d.,]+$/ ) ).toHaveLength( 3 );
|
|
545
|
+
} );
|
|
546
|
+
} );
|
|
547
|
+
|
|
548
|
+
test( 'draws category grid lines at the x axis numTicks', () => {
|
|
549
|
+
const data: SeriesData[] = [
|
|
550
|
+
{
|
|
551
|
+
label: 'Series A',
|
|
552
|
+
data: Array.from( { length: 30 }, ( _, i ) => ( { label: `Day ${ i + 1 }`, value: i } ) ),
|
|
553
|
+
},
|
|
554
|
+
];
|
|
555
|
+
const countColumns = ( numTicks: number ) => {
|
|
556
|
+
const { container, unmount } = renderWithTheme( {
|
|
557
|
+
data,
|
|
558
|
+
gridVisibility: 'y',
|
|
559
|
+
options: { axis: { x: { numTicks } } },
|
|
560
|
+
} );
|
|
561
|
+
// See the visx node constraint at getBarRects.
|
|
562
|
+
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
|
563
|
+
const count = container.querySelectorAll( '.visx-columns line' ).length;
|
|
564
|
+
unmount();
|
|
565
|
+
return count;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
expect( countColumns( 15 ) ).toBeGreaterThan( countColumns( 4 ) );
|
|
569
|
+
} );
|
|
570
|
+
|
|
298
571
|
describe( 'Data Validation', () => {
|
|
299
572
|
test( 'handles empty data array', () => {
|
|
300
573
|
renderWithTheme( { data: [] } );
|
|
@@ -330,18 +603,52 @@ describe( 'BarChart', () => {
|
|
|
330
603
|
expect( screen.getByRole( 'grid', { name: /bar chart/i } ) ).toBeInTheDocument();
|
|
331
604
|
} );
|
|
332
605
|
|
|
333
|
-
test( '
|
|
606
|
+
test( 'renders a bucket with no reading instead of failing the whole chart', () => {
|
|
334
607
|
renderWithTheme( {
|
|
335
608
|
data: [
|
|
336
609
|
{
|
|
337
610
|
label: 'Series A',
|
|
338
611
|
data: [
|
|
339
612
|
{ date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
|
|
613
|
+
{ date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
|
|
614
|
+
],
|
|
615
|
+
options: {},
|
|
616
|
+
},
|
|
617
|
+
],
|
|
618
|
+
} );
|
|
619
|
+
expect( screen.queryByText( /invalid data/i ) ).not.toBeInTheDocument();
|
|
620
|
+
expect( screen.getByRole( 'grid', { name: /bar chart/i } ) ).toBeInTheDocument();
|
|
621
|
+
} );
|
|
622
|
+
|
|
623
|
+
test( 'draws no bar for a bucket with no reading', () => {
|
|
624
|
+
renderWithTheme( {
|
|
625
|
+
data: [
|
|
626
|
+
{
|
|
627
|
+
label: 'Series A',
|
|
628
|
+
data: [
|
|
629
|
+
{ date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
|
|
630
|
+
{ date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
|
|
631
|
+
{ date: new Date( '2024-01-03' ), value: 30, label: 'Jan 3' },
|
|
632
|
+
],
|
|
633
|
+
options: {},
|
|
634
|
+
},
|
|
635
|
+
],
|
|
636
|
+
} );
|
|
637
|
+
expect( getBarRects() ).toHaveLength( 2 );
|
|
638
|
+
} );
|
|
639
|
+
|
|
640
|
+
test( 'still rejects undefined values', () => {
|
|
641
|
+
renderWithTheme( {
|
|
642
|
+
data: [
|
|
643
|
+
{
|
|
644
|
+
label: 'Series A',
|
|
645
|
+
data: [
|
|
340
646
|
{
|
|
341
|
-
date: new Date( '2024-01-
|
|
647
|
+
date: new Date( '2024-01-01' ),
|
|
342
648
|
value: undefined as number | undefined,
|
|
343
|
-
label: 'Jan
|
|
649
|
+
label: 'Jan 1',
|
|
344
650
|
},
|
|
651
|
+
{ date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
|
|
345
652
|
],
|
|
346
653
|
options: {},
|
|
347
654
|
},
|
|
@@ -434,6 +741,71 @@ describe( 'BarChart', () => {
|
|
|
434
741
|
} );
|
|
435
742
|
} );
|
|
436
743
|
|
|
744
|
+
describe( 'Grid tick counts', () => {
|
|
745
|
+
const getPositions = ( selector: string, coordinate: string ) => {
|
|
746
|
+
// eslint-disable-next-line testing-library/no-node-access -- See the visx node constraint above.
|
|
747
|
+
const lines = screen.getByRole( 'grid' ).querySelectorAll( selector );
|
|
748
|
+
return Array.from( lines, line => Number( line.getAttribute( coordinate ) ) );
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
test.each( [ 'x', 'y' ] )( 'aligns an explicit tick count with the %s axis', axis => {
|
|
752
|
+
renderWithTheme( {
|
|
753
|
+
data: [
|
|
754
|
+
{
|
|
755
|
+
label: 'Views',
|
|
756
|
+
data: Array.from( { length: 8 }, ( _, index ) => ( {
|
|
757
|
+
label: `Day ${ index + 1 }`,
|
|
758
|
+
value: ( index + 1 ) * 10,
|
|
759
|
+
} ) ),
|
|
760
|
+
},
|
|
761
|
+
],
|
|
762
|
+
gridVisibility: 'xy',
|
|
763
|
+
options: {
|
|
764
|
+
axis: { [ axis ]: { numTicks: 2, tickFormat: String, axisClassName: 'test-value-axis' } },
|
|
765
|
+
},
|
|
766
|
+
} );
|
|
767
|
+
const grid = getPositions(
|
|
768
|
+
axis === 'x' ? '.visx-columns line' : '.visx-rows line',
|
|
769
|
+
`${ axis }1`
|
|
770
|
+
);
|
|
771
|
+
const ticks = getPositions( '.test-value-axis .visx-axis-tick line', `${ axis }1` );
|
|
772
|
+
expect( ticks.length ).toBeGreaterThan( 0 );
|
|
773
|
+
expect( grid ).toEqual( ticks );
|
|
774
|
+
} );
|
|
775
|
+
|
|
776
|
+
test.each( [ false, true ] )(
|
|
777
|
+
'preserves the default tick count (horizontal=%s)',
|
|
778
|
+
horizontal => {
|
|
779
|
+
const axis = horizontal ? 'x' : 'y';
|
|
780
|
+
const positionsFor = ( axisOptions: object ) => {
|
|
781
|
+
const { unmount } = renderWithTheme( {
|
|
782
|
+
orientation: horizontal ? 'horizontal' : 'vertical',
|
|
783
|
+
gridVisibility: 'xy',
|
|
784
|
+
options: {
|
|
785
|
+
axis: { [ axis ]: { axisClassName: 'test-value-axis', ...axisOptions } },
|
|
786
|
+
},
|
|
787
|
+
} );
|
|
788
|
+
const positions = {
|
|
789
|
+
grid: getPositions(
|
|
790
|
+
horizontal ? '.visx-columns line' : '.visx-rows line',
|
|
791
|
+
`${ axis }1`
|
|
792
|
+
),
|
|
793
|
+
ticks: getPositions( '.test-value-axis .visx-axis-tick line', `${ axis }1` ),
|
|
794
|
+
};
|
|
795
|
+
unmount();
|
|
796
|
+
return positions;
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
const omitted = positionsFor( {} );
|
|
800
|
+
const explicit = positionsFor( { numTicks: 4 } );
|
|
801
|
+
|
|
802
|
+
expect( omitted.ticks.length ).toBeGreaterThan( 0 );
|
|
803
|
+
expect( omitted.grid ).toEqual( omitted.ticks );
|
|
804
|
+
expect( omitted.grid ).toEqual( explicit.grid );
|
|
805
|
+
}
|
|
806
|
+
);
|
|
807
|
+
} );
|
|
808
|
+
|
|
437
809
|
describe( 'Grid Visibility', () => {
|
|
438
810
|
test( 'renders with different grid visibility options', () => {
|
|
439
811
|
const { rerender } = renderWithTheme( { gridVisibility: 'x' } );
|
|
@@ -1655,6 +2027,90 @@ describe( 'BarChart', () => {
|
|
|
1655
2027
|
expect( tooltip ).toHaveTextContent( 'Previous period' );
|
|
1656
2028
|
expect( tooltip ).toHaveTextContent( '25' );
|
|
1657
2029
|
} );
|
|
2030
|
+
|
|
2031
|
+
test( 'reports a bucket with no reading as having no data', async () => {
|
|
2032
|
+
const user = userEvent.setup();
|
|
2033
|
+
renderWithTheme( {
|
|
2034
|
+
withTooltips: true,
|
|
2035
|
+
data: [
|
|
2036
|
+
{
|
|
2037
|
+
label: 'Series A',
|
|
2038
|
+
data: [
|
|
2039
|
+
{ date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
|
|
2040
|
+
{ date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
|
|
2041
|
+
],
|
|
2042
|
+
options: {},
|
|
2043
|
+
},
|
|
2044
|
+
],
|
|
2045
|
+
} );
|
|
2046
|
+
|
|
2047
|
+
screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
|
|
2048
|
+
await user.keyboard( '{ArrowRight}' );
|
|
2049
|
+
|
|
2050
|
+
const tooltip = screen.getByTestId( 'chart-tooltip-0' );
|
|
2051
|
+
expect( tooltip ).toHaveTextContent( 'Jan 1: No data' );
|
|
2052
|
+
expect( tooltip ).not.toHaveTextContent( 'Jan 1: 0' );
|
|
2053
|
+
} );
|
|
2054
|
+
|
|
2055
|
+
test( 'keeps both periods in the tooltip when the comparison period has no reading', async () => {
|
|
2056
|
+
const user = userEvent.setup();
|
|
2057
|
+
renderWithTheme( {
|
|
2058
|
+
withTooltips: true,
|
|
2059
|
+
data: [
|
|
2060
|
+
{
|
|
2061
|
+
label: 'This period',
|
|
2062
|
+
group: 'views',
|
|
2063
|
+
data: [
|
|
2064
|
+
{ date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
|
|
2065
|
+
{ date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
|
|
2066
|
+
],
|
|
2067
|
+
},
|
|
2068
|
+
{
|
|
2069
|
+
label: 'Previous period',
|
|
2070
|
+
group: 'views',
|
|
2071
|
+
options: { type: 'comparison' as const },
|
|
2072
|
+
data: [
|
|
2073
|
+
{ date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
|
|
2074
|
+
{ date: new Date( '2024-01-02' ), value: 25, label: 'Jan 2' },
|
|
2075
|
+
],
|
|
2076
|
+
},
|
|
2077
|
+
],
|
|
2078
|
+
} );
|
|
2079
|
+
|
|
2080
|
+
screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
|
|
2081
|
+
await user.keyboard( '{ArrowRight}' );
|
|
2082
|
+
|
|
2083
|
+
const tooltip = screen.getByTestId( 'chart-tooltip-0' );
|
|
2084
|
+
expect( tooltip ).toHaveTextContent( 'This period: 10' );
|
|
2085
|
+
expect( tooltip ).toHaveTextContent( 'Previous period: No data' );
|
|
2086
|
+
} );
|
|
2087
|
+
|
|
2088
|
+
test( 'draws no comparison shadow for a comparison bucket with no reading', () => {
|
|
2089
|
+
renderWithTheme( {
|
|
2090
|
+
data: [
|
|
2091
|
+
{
|
|
2092
|
+
label: 'This period',
|
|
2093
|
+
group: 'views',
|
|
2094
|
+
data: [
|
|
2095
|
+
{ date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
|
|
2096
|
+
{ date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
|
|
2097
|
+
],
|
|
2098
|
+
},
|
|
2099
|
+
{
|
|
2100
|
+
label: 'Previous period',
|
|
2101
|
+
group: 'views',
|
|
2102
|
+
options: { type: 'comparison' as const },
|
|
2103
|
+
data: [
|
|
2104
|
+
{ date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
|
|
2105
|
+
{ date: new Date( '2024-01-02' ), value: 25, label: 'Jan 2' },
|
|
2106
|
+
],
|
|
2107
|
+
},
|
|
2108
|
+
],
|
|
2109
|
+
} );
|
|
2110
|
+
|
|
2111
|
+
expect( screen.queryByTestId( 'bar-chart-comparison-1-0' ) ).not.toBeInTheDocument();
|
|
2112
|
+
expect( screen.getByTestId( 'bar-chart-comparison-1-1' ) ).toBeInTheDocument();
|
|
2113
|
+
} );
|
|
1658
2114
|
} );
|
|
1659
2115
|
|
|
1660
2116
|
describe( 'Tab Key Navigation', () => {
|
|
@@ -1770,6 +2226,77 @@ describe( 'BarChart', () => {
|
|
|
1770
2226
|
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toBeInTheDocument();
|
|
1771
2227
|
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'Series A' );
|
|
1772
2228
|
} );
|
|
2229
|
+
|
|
2230
|
+
describe( 'with a leading bucket that has no reading', () => {
|
|
2231
|
+
const data = [
|
|
2232
|
+
{
|
|
2233
|
+
label: 'Series A',
|
|
2234
|
+
data: [
|
|
2235
|
+
{ date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
|
|
2236
|
+
{ date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
|
|
2237
|
+
{ date: new Date( '2024-01-03' ), value: 30, label: 'Jan 3' },
|
|
2238
|
+
],
|
|
2239
|
+
options: {},
|
|
2240
|
+
},
|
|
2241
|
+
];
|
|
2242
|
+
|
|
2243
|
+
test( 'outlines nothing when the selected bucket has no bar', async () => {
|
|
2244
|
+
const user = userEvent.setup();
|
|
2245
|
+
renderWithTheme( { withTooltips: true, data } );
|
|
2246
|
+
|
|
2247
|
+
screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
|
|
2248
|
+
await user.keyboard( '{ArrowRight}' );
|
|
2249
|
+
|
|
2250
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'No data' );
|
|
2251
|
+
expect( screen.queryByTestId( 'bar-chart-keyboard-highlight' ) ).not.toBeInTheDocument();
|
|
2252
|
+
} );
|
|
2253
|
+
|
|
2254
|
+
test( 'outlines the first rendered bar for the first bucket with a reading', async () => {
|
|
2255
|
+
const user = userEvent.setup();
|
|
2256
|
+
renderWithTheme( { withTooltips: true, data } );
|
|
2257
|
+
|
|
2258
|
+
screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
|
|
2259
|
+
await user.keyboard( '{ArrowRight}{ArrowRight}' );
|
|
2260
|
+
|
|
2261
|
+
expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveTextContent( '20' );
|
|
2262
|
+
expect( screen.getByTestId( 'bar-chart-keyboard-highlight' ) ).toHaveTextContent(
|
|
2263
|
+
/\.visx-bar:nth-child\(1\) \{/
|
|
2264
|
+
);
|
|
2265
|
+
} );
|
|
2266
|
+
|
|
2267
|
+
test( 'skips the bars an earlier series left out when outlining a later series', async () => {
|
|
2268
|
+
const user = userEvent.setup();
|
|
2269
|
+
const twoSeries = [
|
|
2270
|
+
{
|
|
2271
|
+
label: 'Series A',
|
|
2272
|
+
data: [
|
|
2273
|
+
{ date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
|
|
2274
|
+
{ date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
|
|
2275
|
+
{ date: new Date( '2024-01-03' ), value: 30, label: 'Jan 3' },
|
|
2276
|
+
],
|
|
2277
|
+
options: {},
|
|
2278
|
+
},
|
|
2279
|
+
{
|
|
2280
|
+
label: 'Series B',
|
|
2281
|
+
data: [
|
|
2282
|
+
{ date: new Date( '2024-01-01' ), value: 5, label: 'Jan 1' },
|
|
2283
|
+
{ date: new Date( '2024-01-02' ), value: 15, label: 'Jan 2' },
|
|
2284
|
+
{ date: new Date( '2024-01-03' ), value: 25, label: 'Jan 3' },
|
|
2285
|
+
],
|
|
2286
|
+
options: {},
|
|
2287
|
+
},
|
|
2288
|
+
];
|
|
2289
|
+
renderWithTheme( { withTooltips: true, data: twoSeries } );
|
|
2290
|
+
|
|
2291
|
+
screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
|
|
2292
|
+
await user.keyboard( '{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}' );
|
|
2293
|
+
|
|
2294
|
+
expect( screen.getByTestId( 'chart-tooltip-3' ) ).toHaveTextContent( '15' );
|
|
2295
|
+
expect( screen.getByTestId( 'bar-chart-keyboard-highlight' ) ).toHaveTextContent(
|
|
2296
|
+
/\.visx-bar:nth-child\(4\) \{/
|
|
2297
|
+
);
|
|
2298
|
+
} );
|
|
2299
|
+
} );
|
|
1773
2300
|
} );
|
|
1774
2301
|
|
|
1775
2302
|
test( 'keyboard navigation works with custom tooltip renderer', async () => {
|
|
@@ -2065,6 +2592,83 @@ describe( 'BarChart', () => {
|
|
|
2065
2592
|
} );
|
|
2066
2593
|
} );
|
|
2067
2594
|
|
|
2595
|
+
describe( 'Value axis baseline', () => {
|
|
2596
|
+
it( 'starts the value axis at zero in horizontal charts', () => {
|
|
2597
|
+
const { result } = renderHook( () => useBarChartOptions( steadyTrafficData, true, {} ) );
|
|
2598
|
+
const xScale = result.current.xScale as { domain?: number[] };
|
|
2599
|
+
expect( xScale.domain ).toEqual( [ 0, 989 ] );
|
|
2600
|
+
} );
|
|
2601
|
+
|
|
2602
|
+
it( 'lets a caller opt out of the zero baseline', () => {
|
|
2603
|
+
const { result } = renderHook( () =>
|
|
2604
|
+
useBarChartOptions( steadyTrafficData, false, { yScale: { zero: false } } )
|
|
2605
|
+
);
|
|
2606
|
+
const yScale = result.current.yScale as { domain?: number[] };
|
|
2607
|
+
expect( yScale.domain ).toBeUndefined();
|
|
2608
|
+
} );
|
|
2609
|
+
|
|
2610
|
+
it( 'reads the opt-out from the x scale of a horizontal chart', () => {
|
|
2611
|
+
const { result } = renderHook( () =>
|
|
2612
|
+
useBarChartOptions( steadyTrafficData, true, { xScale: { zero: false } } )
|
|
2613
|
+
);
|
|
2614
|
+
const xScale = result.current.xScale as { domain?: number[] };
|
|
2615
|
+
expect( xScale.domain ).toBeUndefined();
|
|
2616
|
+
} );
|
|
2617
|
+
|
|
2618
|
+
it( 'keeps an explicit domain as given', () => {
|
|
2619
|
+
const { result } = renderHook( () =>
|
|
2620
|
+
useBarChartOptions( steadyTrafficData, false, { yScale: { domain: [ 900, 1000 ] } } )
|
|
2621
|
+
);
|
|
2622
|
+
const yScale = result.current.yScale as { domain?: number[] };
|
|
2623
|
+
expect( yScale.domain ).toEqual( [ 900, 1000 ] );
|
|
2624
|
+
} );
|
|
2625
|
+
|
|
2626
|
+
it( 'renders an explicit domain without stretching it to zero', () => {
|
|
2627
|
+
render(
|
|
2628
|
+
<BarChart
|
|
2629
|
+
data={ steadyTrafficData }
|
|
2630
|
+
width={ 400 }
|
|
2631
|
+
height={ 300 }
|
|
2632
|
+
options={ { yScale: { domain: [ 900, 1000 ] } } }
|
|
2633
|
+
/>
|
|
2634
|
+
);
|
|
2635
|
+
const heights = Array.from( getBarRects() ).map( bar =>
|
|
2636
|
+
parseFloat( bar.getAttribute( 'height' ) || '0' )
|
|
2637
|
+
);
|
|
2638
|
+
// 921 sits at 21% of a 900–1000 axis; on a 0–1000 axis it would be 92%.
|
|
2639
|
+
expect( Math.min( ...heights ) / Math.max( ...heights ) ).toBeLessThan( 0.5 );
|
|
2640
|
+
} );
|
|
2641
|
+
|
|
2642
|
+
it( 'keeps zero in the domain when the caller opts out but a comparison series is present', () => {
|
|
2643
|
+
const data = [
|
|
2644
|
+
...steadyTrafficData,
|
|
2645
|
+
{
|
|
2646
|
+
label: 'Previous',
|
|
2647
|
+
options: { type: 'comparison' as const },
|
|
2648
|
+
data: steadyTrafficData[ 0 ].data.map( point => ( {
|
|
2649
|
+
...point,
|
|
2650
|
+
value: point.value - 10,
|
|
2651
|
+
} ) ),
|
|
2652
|
+
},
|
|
2653
|
+
];
|
|
2654
|
+
const { result } = renderHook( () =>
|
|
2655
|
+
useBarChartOptions( data, false, { yScale: { zero: false } } )
|
|
2656
|
+
);
|
|
2657
|
+
const yScale = result.current.yScale as { domain?: number[] };
|
|
2658
|
+
expect( yScale.domain ).toEqual( [ 0, 989 ] );
|
|
2659
|
+
} );
|
|
2660
|
+
|
|
2661
|
+
it( 'renders every bar of steady traffic at most a few percent shorter than the tallest', () => {
|
|
2662
|
+
render( <BarChart data={ steadyTrafficData } width={ 400 } height={ 300 } /> );
|
|
2663
|
+
const heights = Array.from( getBarRects() ).map( bar =>
|
|
2664
|
+
parseFloat( bar.getAttribute( 'height' ) || '0' )
|
|
2665
|
+
);
|
|
2666
|
+
expect( heights ).toHaveLength( 7 );
|
|
2667
|
+
const tallest = Math.max( ...heights );
|
|
2668
|
+
expect( Math.min( ...heights ) / tallest ).toBeGreaterThan( 0.9 );
|
|
2669
|
+
} );
|
|
2670
|
+
} );
|
|
2671
|
+
|
|
2068
2672
|
describe( 'Comparison Series', () => {
|
|
2069
2673
|
it( 'renders a translucent shadow behind the primary bar for a comparison series', () => {
|
|
2070
2674
|
const data = [
|