@automattic/charts 2.0.0 → 3.0.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/TOKENS.md +107 -48
- package/dist/index.cjs +584 -209
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +97 -43
- package/dist/index.d.cts +46 -41
- package/dist/index.d.ts +46 -41
- package/dist/index.js +582 -210
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/charts/area-chart/area-chart.module.scss +8 -3
- package/src/charts/area-chart/area-chart.tsx +3 -5
- package/src/charts/bar-chart/bar-chart.module.scss +14 -6
- package/src/charts/bar-chart/bar-chart.tsx +24 -15
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +197 -30
- package/src/charts/bar-chart/test/bar-chart.test.tsx +685 -1
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +17 -12
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +107 -86
- package/src/charts/conversion-funnel-chart/test/chart-scope.test.tsx +36 -0
- package/src/charts/geo-chart/geo-chart.tsx +12 -4
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +13 -9
- package/src/charts/heatmap-chart/heatmap-chart.tsx +12 -5
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +13 -7
- package/src/charts/leaderboard-chart/types.ts +0 -2
- package/src/charts/line-chart/line-chart.module.scss +10 -5
- package/src/charts/line-chart/line-chart.tsx +3 -5
- package/src/charts/line-chart/private/line-chart-annotation.tsx +4 -4
- package/src/charts/pie-chart/pie-chart.tsx +4 -1
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +4 -1
- package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
- package/src/charts/private/test/time-axis.test.ts +247 -2
- package/src/charts/private/time-axis.ts +252 -63
- package/src/charts/private/with-responsive/test/with-responsive.test.tsx +16 -0
- package/src/charts/private/with-responsive/with-responsive.tsx +18 -11
- package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
- package/src/components/legend/private/base-legend.tsx +3 -1
- package/src/components/legend/test/legend.test.tsx +2 -1
- package/src/components/tooltip/accessible-tooltip.tsx +4 -2
- package/src/components/tooltip/base-tooltip.tsx +8 -1
- package/src/components/trend-indicator/test/trend-indicator.test.tsx +22 -1
- package/src/components/trend-indicator/trend-indicator.module.scss +3 -6
- package/src/components/trend-indicator/trend-indicator.tsx +3 -0
- package/src/hooks/test/use-chart-margin.test.tsx +60 -0
- package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +28 -0
- package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +89 -0
- package/src/hooks/test/use-xychart-theme.test.tsx +25 -0
- package/src/hooks/use-chart-margin.tsx +3 -4
- package/src/hooks/use-xychart-theme.ts +19 -11
- package/src/index.ts +9 -2
- package/src/providers/chart-context/global-charts-provider.tsx +35 -12
- package/src/providers/chart-context/private/theme-override-vars.ts +87 -0
- package/src/providers/chart-context/private/with-catalog-pointers.ts +50 -0
- package/src/providers/chart-context/test/chart-context.test.tsx +0 -40
- package/src/providers/chart-context/test/chart-scope-class.test.tsx +19 -0
- package/src/providers/chart-context/test/sparse-theme.test.ts +35 -0
- package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +29 -0
- package/src/providers/chart-context/test/theme-override-vars.test.ts +118 -0
- package/src/providers/chart-context/test/with-catalog-pointers.test.ts +47 -0
- package/src/providers/chart-context/themes.ts +18 -27
- package/src/providers/chart-context/types.ts +0 -3
- package/src/providers/chart-scope/chart-scope-context.ts +8 -0
- package/src/providers/chart-scope/index.ts +3 -0
- package/src/providers/chart-scope/test/chart-scope.test.tsx +24 -0
- package/src/providers/chart-scope/test/use-standalone-scope-class.test.tsx +20 -0
- package/src/providers/chart-scope/use-chart-scope-element.ts +9 -0
- package/src/providers/chart-scope/use-standalone-scope-class.ts +13 -0
- package/src/providers/index.ts +1 -0
- package/src/styles/chart-scope-class.ts +6 -0
- package/src/styles/chart-scope.scss +83 -0
- package/src/styles/test/chart-scope.test.ts +165 -0
- package/src/types.ts +6 -40
- package/src/utils/index.ts +1 -1
- package/src/utils/resolve-css-var.ts +69 -28
- package/src/utils/test/create-css-variable-resolver.test.ts +83 -0
- package/src/charts/private/grid-control/grid-control.module.scss +0 -8
- package/src/charts/private/grid-control/grid-control.tsx +0 -26
- package/src/charts/private/grid-control/index.ts +0 -1
- package/src/charts/private/grid-control/test/grid-control.test.tsx +0 -56
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { render, renderHook, screen } from '@testing-library/react';
|
|
1
|
+
import { render, renderHook, screen, within } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
3
|
import { GlobalChartsProvider } from '../../../providers';
|
|
4
4
|
import BarChart from '../bar-chart';
|
|
5
5
|
import { useBarChartOptions } from '../private';
|
|
6
|
+
import type { SeriesData } from '../../../types';
|
|
6
7
|
|
|
7
8
|
// Mock useElementSize to return non-zero dimensions in jsdom so charts render
|
|
8
9
|
const mockRefCallback = jest.fn();
|
|
@@ -218,6 +219,689 @@ describe( 'BarChart', () => {
|
|
|
218
219
|
} );
|
|
219
220
|
} );
|
|
220
221
|
|
|
222
|
+
describe( 'Bar chart options memoization', () => {
|
|
223
|
+
const stableData: SeriesData[] = [
|
|
224
|
+
{
|
|
225
|
+
label: 'Views',
|
|
226
|
+
data: [
|
|
227
|
+
{ date: new Date( 2026, 0, 1 ), value: 1 },
|
|
228
|
+
{ date: new Date( 2026, 0, 2 ), value: 2 },
|
|
229
|
+
],
|
|
230
|
+
options: {},
|
|
231
|
+
},
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
test( 'returns the same options when the caller passes an equal object literal', () => {
|
|
235
|
+
// The literal is rebuilt on every render, so only a deep comparison can
|
|
236
|
+
// keep the memos below it from recomputing.
|
|
237
|
+
const { result, rerender } = renderHook( () =>
|
|
238
|
+
useBarChartOptions( stableData, false, { axis: { x: { numTicks: 6 } } } )
|
|
239
|
+
);
|
|
240
|
+
const first = result.current;
|
|
241
|
+
|
|
242
|
+
rerender();
|
|
243
|
+
|
|
244
|
+
expect( result.current ).toBe( first );
|
|
245
|
+
} );
|
|
246
|
+
|
|
247
|
+
test( 'still recomputes when the options actually change', () => {
|
|
248
|
+
const { result, rerender } = renderHook(
|
|
249
|
+
( { numTicks }: { numTicks: number } ) =>
|
|
250
|
+
useBarChartOptions( stableData, false, { axis: { x: { numTicks } } } ),
|
|
251
|
+
{ initialProps: { numTicks: 6 } }
|
|
252
|
+
);
|
|
253
|
+
const first = result.current;
|
|
254
|
+
|
|
255
|
+
rerender( { numTicks: 3 } );
|
|
256
|
+
|
|
257
|
+
expect( result.current ).not.toBe( first );
|
|
258
|
+
expect( result.current.axis.x.numTicks ).toBe( 3 );
|
|
259
|
+
} );
|
|
260
|
+
|
|
261
|
+
test( 'returns the same options when nothing changed and no options were passed', () => {
|
|
262
|
+
const { result, rerender } = renderHook( () => useBarChartOptions( stableData, false ) );
|
|
263
|
+
const first = result.current;
|
|
264
|
+
|
|
265
|
+
rerender();
|
|
266
|
+
|
|
267
|
+
expect( result.current ).toBe( first );
|
|
268
|
+
} );
|
|
269
|
+
} );
|
|
270
|
+
|
|
271
|
+
describe( 'Band domain', () => {
|
|
272
|
+
const dated = ( year: number, month: number, day: number, value: number ) => ( {
|
|
273
|
+
date: new Date( year, month, day ),
|
|
274
|
+
value,
|
|
275
|
+
} );
|
|
276
|
+
|
|
277
|
+
// Scoped to the chart: @visx/text parks a measuring node on document.body,
|
|
278
|
+
// which a negative assertion would otherwise match.
|
|
279
|
+
const inChart = () => within( screen.getByRole( 'grid', { name: /bar chart/i } ) );
|
|
280
|
+
|
|
281
|
+
test( 'leaves tick values alone when a bucket is labelled rather than dated', () => {
|
|
282
|
+
// visx builds the band domain from `label || date`, so a labelled bucket
|
|
283
|
+
// is not a `Date` on the axis at all. Choosing `Date` tick values for a
|
|
284
|
+
// domain like that puts a tick on a key `scaleBand` does not hold.
|
|
285
|
+
const { result } = renderHook( () =>
|
|
286
|
+
useBarChartOptions(
|
|
287
|
+
[
|
|
288
|
+
{
|
|
289
|
+
label: 'Views',
|
|
290
|
+
data: [
|
|
291
|
+
dated( 2026, 0, 1, 1 ),
|
|
292
|
+
{ ...dated( 2026, 0, 2, 2 ), label: 'Launch day' },
|
|
293
|
+
dated( 2026, 0, 3, 3 ),
|
|
294
|
+
],
|
|
295
|
+
options: {},
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
false
|
|
299
|
+
)
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
expect( result.current.axis.x.tickValues ).toBeUndefined();
|
|
303
|
+
} );
|
|
304
|
+
|
|
305
|
+
test( 'labels a bucket that carries a label, on an otherwise dated axis', () => {
|
|
306
|
+
// `hasLabels` samples only the first point, so this axis keeps the time
|
|
307
|
+
// formatter; the labelled bucket still has to render as itself rather
|
|
308
|
+
// than as a date parsed out of its label.
|
|
309
|
+
renderWithTheme( {
|
|
310
|
+
data: [
|
|
311
|
+
{
|
|
312
|
+
label: 'Views',
|
|
313
|
+
data: [
|
|
314
|
+
dated( 2026, 0, 1, 1 ),
|
|
315
|
+
{ ...dated( 2026, 0, 2, 2 ), label: 'Launch day' },
|
|
316
|
+
dated( 2026, 0, 3, 3 ),
|
|
317
|
+
],
|
|
318
|
+
options: {},
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
} );
|
|
322
|
+
|
|
323
|
+
expect( inChart().getByText( 'Launch day' ) ).toBeInTheDocument();
|
|
324
|
+
expect( inChart().queryByText( /Invalid Date/ ) ).not.toBeInTheDocument();
|
|
325
|
+
} );
|
|
326
|
+
|
|
327
|
+
test( 'ignores comparison series, which visx never puts on the band scale', () => {
|
|
328
|
+
// A comparison series carrying its own dates is a misuse the chart already
|
|
329
|
+
// warns about; the point here is that it does not also break the axis.
|
|
330
|
+
const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
renderWithTheme( {
|
|
334
|
+
data: [
|
|
335
|
+
{
|
|
336
|
+
label: 'This period',
|
|
337
|
+
data: [ dated( 2026, 0, 1, 1 ), dated( 2026, 0, 4, 2 ) ],
|
|
338
|
+
options: {},
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
label: 'Prior period',
|
|
342
|
+
data: [ dated( 2025, 11, 26, 1 ), dated( 2025, 11, 29, 2 ) ],
|
|
343
|
+
options: { type: 'comparison' },
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
} );
|
|
347
|
+
|
|
348
|
+
expect( inChart().queryByText( 'Dec 26' ) ).not.toBeInTheDocument();
|
|
349
|
+
expect( inChart().queryByText( 'Dec 29' ) ).not.toBeInTheDocument();
|
|
350
|
+
expect( inChart().getByText( 'Jan 1' ) ).toBeInTheDocument();
|
|
351
|
+
} finally {
|
|
352
|
+
warn.mockRestore();
|
|
353
|
+
}
|
|
354
|
+
} );
|
|
355
|
+
|
|
356
|
+
test( 'drops the buckets of a series the legend has hidden', async () => {
|
|
357
|
+
const user = userEvent.setup();
|
|
358
|
+
renderWithTheme( {
|
|
359
|
+
chartId: 'hidden-series',
|
|
360
|
+
showLegend: true,
|
|
361
|
+
legend: { interactive: true },
|
|
362
|
+
data: [
|
|
363
|
+
{
|
|
364
|
+
label: 'Long',
|
|
365
|
+
data: [
|
|
366
|
+
dated( 2026, 0, 1, 1 ),
|
|
367
|
+
dated( 2026, 0, 3, 2 ),
|
|
368
|
+
dated( 2026, 0, 5, 3 ),
|
|
369
|
+
dated( 2026, 0, 7, 4 ),
|
|
370
|
+
],
|
|
371
|
+
options: {},
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
label: 'Short',
|
|
375
|
+
data: [ dated( 2026, 0, 1, 5 ), dated( 2026, 0, 3, 6 ) ],
|
|
376
|
+
options: {},
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
} );
|
|
380
|
+
|
|
381
|
+
await user.click( screen.getByText( 'Long' ) );
|
|
382
|
+
|
|
383
|
+
expect( inChart().queryByText( 'Jan 5' ) ).not.toBeInTheDocument();
|
|
384
|
+
expect( inChart().queryByText( 'Jan 7' ) ).not.toBeInTheDocument();
|
|
385
|
+
} );
|
|
386
|
+
|
|
387
|
+
test( 'labels a bucket that carries a label in the tooltip too', () => {
|
|
388
|
+
const { result } = renderHook( () =>
|
|
389
|
+
useBarChartOptions(
|
|
390
|
+
[
|
|
391
|
+
{
|
|
392
|
+
label: 'Views',
|
|
393
|
+
data: [ dated( 2026, 0, 1, 1 ), { ...dated( 2026, 0, 2, 2 ), label: 'Launch day' } ],
|
|
394
|
+
options: {},
|
|
395
|
+
},
|
|
396
|
+
],
|
|
397
|
+
false
|
|
398
|
+
)
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
// visx calls a tick formatter with (value, index, values).
|
|
402
|
+
const format = result.current.tooltip.labelFormatter;
|
|
403
|
+
expect( format( 'Launch day', 0, [] ) ).toBe( 'Launch day' );
|
|
404
|
+
expect( format( new Date( 2026, 0, 1 ), 0, [] ) ).toMatch( /2026/ );
|
|
405
|
+
} );
|
|
406
|
+
|
|
407
|
+
test( 'keeps only the later of two series sharing a label, as the registry does', () => {
|
|
408
|
+
const warn = jest.spyOn( console, 'error' ).mockImplementation( () => {} );
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
const { result } = renderHook( () =>
|
|
412
|
+
useBarChartOptions(
|
|
413
|
+
[
|
|
414
|
+
{ label: 'Dup', data: [ dated( 2026, 0, 1, 1 ) ], options: {} },
|
|
415
|
+
{ label: 'Dup', data: [ dated( 2026, 0, 5, 2 ) ], options: {} },
|
|
416
|
+
],
|
|
417
|
+
false
|
|
418
|
+
)
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
expect( result.current.axis.x.tickValues ).toEqual( [ new Date( 2026, 0, 5 ) ] );
|
|
422
|
+
} finally {
|
|
423
|
+
warn.mockRestore();
|
|
424
|
+
}
|
|
425
|
+
} );
|
|
426
|
+
|
|
427
|
+
test( 'puts the band tick values on the y axis when horizontal', () => {
|
|
428
|
+
const { result } = renderHook( () =>
|
|
429
|
+
useBarChartOptions(
|
|
430
|
+
[
|
|
431
|
+
{
|
|
432
|
+
label: 'Views',
|
|
433
|
+
data: [ dated( 2026, 0, 1, 1 ), dated( 2026, 0, 2, 2 ) ],
|
|
434
|
+
options: {},
|
|
435
|
+
},
|
|
436
|
+
],
|
|
437
|
+
true
|
|
438
|
+
)
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
expect( result.current.axis.y.tickValues ).toEqual( [
|
|
442
|
+
new Date( 2026, 0, 1 ),
|
|
443
|
+
new Date( 2026, 0, 2 ),
|
|
444
|
+
] );
|
|
445
|
+
expect( result.current.axis.x.tickValues ).toBeUndefined();
|
|
446
|
+
} );
|
|
447
|
+
|
|
448
|
+
test( 'keeps every dated bucket across series, in the order visx concatenates them', () => {
|
|
449
|
+
const { result } = renderHook( () =>
|
|
450
|
+
useBarChartOptions(
|
|
451
|
+
[
|
|
452
|
+
{
|
|
453
|
+
label: 'A',
|
|
454
|
+
data: [ dated( 2026, 0, 1, 1 ), dated( 2026, 0, 3, 3 ) ],
|
|
455
|
+
options: {},
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
label: 'B',
|
|
459
|
+
data: [ dated( 2026, 0, 1, 4 ), dated( 2026, 0, 2, 5 ) ],
|
|
460
|
+
options: {},
|
|
461
|
+
},
|
|
462
|
+
],
|
|
463
|
+
false
|
|
464
|
+
)
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
expect( result.current.axis.x.tickValues ).toEqual( [
|
|
468
|
+
new Date( 2026, 0, 1 ),
|
|
469
|
+
new Date( 2026, 0, 3 ),
|
|
470
|
+
new Date( 2026, 0, 2 ),
|
|
471
|
+
] );
|
|
472
|
+
} );
|
|
473
|
+
} );
|
|
474
|
+
|
|
475
|
+
describe( 'Time axis ticks', () => {
|
|
476
|
+
const distinctTexts = ( elements: HTMLElement[] ) =>
|
|
477
|
+
new Set( elements.map( el => el.textContent ) );
|
|
478
|
+
|
|
479
|
+
// @visx/text measures label widths through one reusable <text> node parked
|
|
480
|
+
// on document.body, which outlives RTL's cleanup still holding the last
|
|
481
|
+
// string measured. Query inside the chart so a previous test's measurement
|
|
482
|
+
// can't answer for this one's axis.
|
|
483
|
+
const inChart = () => within( screen.getByRole( 'grid', { name: /bar chart/i } ) );
|
|
484
|
+
|
|
485
|
+
test( 'renders distinct date ticks for daily buckets within a year', () => {
|
|
486
|
+
renderWithTheme( {
|
|
487
|
+
width: 800,
|
|
488
|
+
data: [
|
|
489
|
+
{
|
|
490
|
+
label: 'Series A',
|
|
491
|
+
data: Array.from( { length: 14 }, ( _, i ) => ( {
|
|
492
|
+
date: new Date( 2024, 0, 1 + i ),
|
|
493
|
+
value: 10 + i,
|
|
494
|
+
} ) ),
|
|
495
|
+
options: {},
|
|
496
|
+
},
|
|
497
|
+
],
|
|
498
|
+
} );
|
|
499
|
+
|
|
500
|
+
const ticks = screen.getAllByText(
|
|
501
|
+
/^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d+$/
|
|
502
|
+
);
|
|
503
|
+
expect( distinctTexts( ticks ).size ).toBeGreaterThan( 1 );
|
|
504
|
+
} );
|
|
505
|
+
|
|
506
|
+
test( 'renders distinct hour ticks for sub-daily buckets in a single day', () => {
|
|
507
|
+
renderWithTheme( {
|
|
508
|
+
width: 800,
|
|
509
|
+
data: [
|
|
510
|
+
{
|
|
511
|
+
label: 'Series A',
|
|
512
|
+
data: Array.from( { length: 12 }, ( _, i ) => ( {
|
|
513
|
+
date: new Date( 2024, 0, 1, i ),
|
|
514
|
+
value: 10 + i,
|
|
515
|
+
} ) ),
|
|
516
|
+
options: {},
|
|
517
|
+
},
|
|
518
|
+
],
|
|
519
|
+
} );
|
|
520
|
+
|
|
521
|
+
const ticks = screen.getAllByText( /^\d{1,2}\s(AM|PM)$/ );
|
|
522
|
+
expect( distinctTexts( ticks ).size ).toBeGreaterThan( 1 );
|
|
523
|
+
} );
|
|
524
|
+
|
|
525
|
+
test( 'renders date ticks, not hour ticks, for a two-point daily series', () => {
|
|
526
|
+
renderWithTheme( {
|
|
527
|
+
width: 800,
|
|
528
|
+
data: [
|
|
529
|
+
{
|
|
530
|
+
label: 'Series A',
|
|
531
|
+
data: [
|
|
532
|
+
{ date: new Date( 2024, 0, 1 ), value: 10 },
|
|
533
|
+
{ date: new Date( 2024, 0, 2 ), value: 20 },
|
|
534
|
+
],
|
|
535
|
+
options: {},
|
|
536
|
+
},
|
|
537
|
+
],
|
|
538
|
+
} );
|
|
539
|
+
|
|
540
|
+
expect( screen.getByText( 'Jan 1' ) ).toBeInTheDocument();
|
|
541
|
+
expect( screen.getByText( 'Jan 2' ) ).toBeInTheDocument();
|
|
542
|
+
expect( screen.queryByText( /12\sAM/ ) ).not.toBeInTheDocument();
|
|
543
|
+
} );
|
|
544
|
+
|
|
545
|
+
test( 'renders distinct year ticks for series spanning multiple years', () => {
|
|
546
|
+
renderWithTheme( {
|
|
547
|
+
width: 800,
|
|
548
|
+
data: [
|
|
549
|
+
{
|
|
550
|
+
label: 'Series A',
|
|
551
|
+
data: Array.from( { length: 4 }, ( _, i ) => ( {
|
|
552
|
+
date: new Date( 2021 + i, 0, 1 ),
|
|
553
|
+
value: 10 + i,
|
|
554
|
+
} ) ),
|
|
555
|
+
options: {},
|
|
556
|
+
},
|
|
557
|
+
],
|
|
558
|
+
} );
|
|
559
|
+
|
|
560
|
+
const ticks = screen.getAllByText( /^\d{4}$/ );
|
|
561
|
+
expect( distinctTexts( ticks ).size ).toBeGreaterThan( 1 );
|
|
562
|
+
} );
|
|
563
|
+
|
|
564
|
+
test( 'renders date ticks for daily buckets whose gaps shrink to 23 hours', () => {
|
|
565
|
+
// The gaps are built at 23 hours rather than dated across a real
|
|
566
|
+
// spring-forward, because the suite pins TZ=UTC and so has no DST to
|
|
567
|
+
// straddle. A strict 24h rule would read these as sub-daily buckets and
|
|
568
|
+
// label them by the hour.
|
|
569
|
+
const start = new Date( 2026, 2, 8 );
|
|
570
|
+
renderWithTheme( {
|
|
571
|
+
width: 800,
|
|
572
|
+
data: [
|
|
573
|
+
{
|
|
574
|
+
label: 'Series A',
|
|
575
|
+
data: [ 0, 23, 46 ].map( ( offsetHours, i ) => ( {
|
|
576
|
+
date: new Date( start.getTime() + offsetHours * 60 * 60 * 1000 ),
|
|
577
|
+
value: 10 * ( i + 1 ),
|
|
578
|
+
} ) ),
|
|
579
|
+
options: {},
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
} );
|
|
583
|
+
|
|
584
|
+
expect( screen.getAllByText( /^Mar \d+$/ ).length ).toBeGreaterThan( 0 );
|
|
585
|
+
expect( screen.queryByText( /\d{1,2}\s(AM|PM)/ ) ).not.toBeInTheDocument();
|
|
586
|
+
} );
|
|
587
|
+
|
|
588
|
+
test( 'dates a midnight tick for sub-daily buckets spanning days', () => {
|
|
589
|
+
renderWithTheme( {
|
|
590
|
+
width: 800,
|
|
591
|
+
data: [
|
|
592
|
+
{
|
|
593
|
+
label: 'Series A',
|
|
594
|
+
data: Array.from( { length: 48 }, ( _, i ) => ( {
|
|
595
|
+
date: new Date( 2026, 7, 2, i ),
|
|
596
|
+
value: 10 + i,
|
|
597
|
+
} ) ),
|
|
598
|
+
options: {},
|
|
599
|
+
},
|
|
600
|
+
],
|
|
601
|
+
} );
|
|
602
|
+
|
|
603
|
+
// Band ticks are sampled by index, so without steering they land on
|
|
604
|
+
// bare hours belonging to days the axis never names.
|
|
605
|
+
expect( inChart().getByText( 'Aug 2' ) ).toBeInTheDocument();
|
|
606
|
+
expect( inChart().getByText( 'Aug 3' ) ).toBeInTheDocument();
|
|
607
|
+
} );
|
|
608
|
+
|
|
609
|
+
test( 'dates every tick for sub-daily buckets spanning a week', () => {
|
|
610
|
+
renderWithTheme( {
|
|
611
|
+
width: 800,
|
|
612
|
+
data: [
|
|
613
|
+
{
|
|
614
|
+
label: 'Series A',
|
|
615
|
+
data: Array.from( { length: 168 }, ( _, i ) => ( {
|
|
616
|
+
date: new Date( 2026, 7, 2, i ),
|
|
617
|
+
value: 10 + i,
|
|
618
|
+
} ) ),
|
|
619
|
+
options: {},
|
|
620
|
+
},
|
|
621
|
+
],
|
|
622
|
+
} );
|
|
623
|
+
|
|
624
|
+
// Too long for a step within one day to fit the tick count, so the axis
|
|
625
|
+
// has to step whole days to keep naming them.
|
|
626
|
+
expect( inChart().queryByText( /\d{1,2}\s(AM|PM)/ ) ).not.toBeInTheDocument();
|
|
627
|
+
expect( inChart().getByText( 'Aug 2' ) ).toBeInTheDocument();
|
|
628
|
+
expect( inChart().getByText( 'Aug 8' ) ).toBeInTheDocument();
|
|
629
|
+
} );
|
|
630
|
+
|
|
631
|
+
test( 'names the year for monthly buckets that do not start in January', () => {
|
|
632
|
+
renderWithTheme( {
|
|
633
|
+
width: 800,
|
|
634
|
+
data: [
|
|
635
|
+
{
|
|
636
|
+
label: 'Series A',
|
|
637
|
+
data: Array.from( { length: 36 }, ( _, i ) => ( {
|
|
638
|
+
date: new Date( 2023, 6 + i, 1 ),
|
|
639
|
+
value: 10 + i,
|
|
640
|
+
} ) ),
|
|
641
|
+
options: {},
|
|
642
|
+
},
|
|
643
|
+
],
|
|
644
|
+
} );
|
|
645
|
+
|
|
646
|
+
const ticks = inChart().getAllByText( /^\d{4}$/ );
|
|
647
|
+
expect( distinctTexts( ticks ) ).toEqual( new Set( [ '2024', '2025', '2026' ] ) );
|
|
648
|
+
} );
|
|
649
|
+
|
|
650
|
+
test( 'reads tickResolution off the y axis on a horizontal chart', () => {
|
|
651
|
+
renderWithTheme( {
|
|
652
|
+
width: 800,
|
|
653
|
+
orientation: 'horizontal',
|
|
654
|
+
data: [
|
|
655
|
+
{
|
|
656
|
+
label: 'Series A',
|
|
657
|
+
data: [ { date: new Date( 2024, 0, 1, 13 ), value: 10 } ],
|
|
658
|
+
options: {},
|
|
659
|
+
},
|
|
660
|
+
],
|
|
661
|
+
options: { axis: { y: { tickResolution: 'hour' } } },
|
|
662
|
+
} );
|
|
663
|
+
|
|
664
|
+
// The dates move to the y axis with the orientation, so the hint has to
|
|
665
|
+
// follow them; read off `axis.x` this would fall back to a date tick.
|
|
666
|
+
expect( inChart().getByText( /^1\sPM$/ ) ).toBeInTheDocument();
|
|
667
|
+
} );
|
|
668
|
+
|
|
669
|
+
test( 'renders an hour tick when tickResolution declares the buckets sub-daily', () => {
|
|
670
|
+
// A single bucket has no point spacing to infer the resolution from,
|
|
671
|
+
// so only the declared resolution can reach the hour format.
|
|
672
|
+
renderWithTheme( {
|
|
673
|
+
width: 800,
|
|
674
|
+
data: [
|
|
675
|
+
{
|
|
676
|
+
label: 'Series A',
|
|
677
|
+
data: [ { date: new Date( 2024, 0, 1, 13 ), value: 10 } ],
|
|
678
|
+
options: {},
|
|
679
|
+
},
|
|
680
|
+
],
|
|
681
|
+
options: { axis: { x: { tickResolution: 'hour' } } },
|
|
682
|
+
} );
|
|
683
|
+
|
|
684
|
+
expect( inChart().getByText( /^1\sPM$/ ) ).toBeInTheDocument();
|
|
685
|
+
} );
|
|
686
|
+
} );
|
|
687
|
+
|
|
688
|
+
describe( 'Time series tooltip labels', () => {
|
|
689
|
+
const optionsFor = (
|
|
690
|
+
data: Parameters< typeof useBarChartOptions >[ 0 ],
|
|
691
|
+
options?: Parameters< typeof useBarChartOptions >[ 2 ]
|
|
692
|
+
) => renderHook( () => useBarChartOptions( data, false, options ) ).result.current;
|
|
693
|
+
|
|
694
|
+
test( 'names the full date for daily buckets', () => {
|
|
695
|
+
const { tooltip } = optionsFor( [
|
|
696
|
+
{
|
|
697
|
+
label: 'Series A',
|
|
698
|
+
data: Array.from( { length: 14 }, ( _, i ) => ( {
|
|
699
|
+
date: new Date( 2024, 0, 1 + i ),
|
|
700
|
+
value: 10 + i,
|
|
701
|
+
} ) ),
|
|
702
|
+
options: {},
|
|
703
|
+
},
|
|
704
|
+
] );
|
|
705
|
+
|
|
706
|
+
expect( tooltip.labelFormatter( new Date( 2024, 0, 3 ).getTime(), 0, [] ) ).toBe(
|
|
707
|
+
'January 3, 2024'
|
|
708
|
+
);
|
|
709
|
+
} );
|
|
710
|
+
|
|
711
|
+
test( 'names the month, without a day, for monthly buckets', () => {
|
|
712
|
+
const { tooltip } = optionsFor( [
|
|
713
|
+
{
|
|
714
|
+
label: 'Series A',
|
|
715
|
+
data: Array.from( { length: 24 }, ( _, i ) => ( {
|
|
716
|
+
date: new Date( 2024, i, 1 ),
|
|
717
|
+
value: 10 + i,
|
|
718
|
+
} ) ),
|
|
719
|
+
options: {},
|
|
720
|
+
},
|
|
721
|
+
] );
|
|
722
|
+
|
|
723
|
+
expect( tooltip.labelFormatter( new Date( 2025, 2, 1 ).getTime(), 0, [] ) ).toBe(
|
|
724
|
+
'March 2025'
|
|
725
|
+
);
|
|
726
|
+
} );
|
|
727
|
+
|
|
728
|
+
test( 'names only the year for yearly buckets', () => {
|
|
729
|
+
const { tooltip } = optionsFor( [
|
|
730
|
+
{
|
|
731
|
+
label: 'Series A',
|
|
732
|
+
data: Array.from( { length: 4 }, ( _, i ) => ( {
|
|
733
|
+
date: new Date( 2023 + i, 0, 1 ),
|
|
734
|
+
value: 10 + i,
|
|
735
|
+
} ) ),
|
|
736
|
+
options: {},
|
|
737
|
+
},
|
|
738
|
+
] );
|
|
739
|
+
|
|
740
|
+
expect( tooltip.labelFormatter( new Date( 2024, 0, 1 ).getTime(), 0, [] ) ).toBe( '2024' );
|
|
741
|
+
} );
|
|
742
|
+
|
|
743
|
+
test( 'follows a declared tickResolution coarser than the point spacing', () => {
|
|
744
|
+
const { tooltip } = optionsFor(
|
|
745
|
+
[
|
|
746
|
+
{
|
|
747
|
+
label: 'Series A',
|
|
748
|
+
data: Array.from( { length: 24 }, ( _, i ) => ( {
|
|
749
|
+
date: new Date( 2024, i, 1 ),
|
|
750
|
+
value: 10 + i,
|
|
751
|
+
} ) ),
|
|
752
|
+
options: {},
|
|
753
|
+
},
|
|
754
|
+
],
|
|
755
|
+
{ axis: { x: { tickResolution: 'year' } } }
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
expect( tooltip.labelFormatter( new Date( 2025, 2, 1 ).getTime(), 0, [] ) ).toBe( '2025' );
|
|
759
|
+
} );
|
|
760
|
+
|
|
761
|
+
test( 'detects sub-daily resolution past a leading gap', () => {
|
|
762
|
+
const { tooltip } = optionsFor( [
|
|
763
|
+
{
|
|
764
|
+
label: 'Series A',
|
|
765
|
+
data: [
|
|
766
|
+
{ date: new Date( 2024, 0, 1 ), value: 10 },
|
|
767
|
+
{ date: new Date( 2024, 0, 3 ), value: 20 },
|
|
768
|
+
{ date: new Date( 2024, 0, 3, 6 ), value: 30 },
|
|
769
|
+
],
|
|
770
|
+
options: {},
|
|
771
|
+
},
|
|
772
|
+
] );
|
|
773
|
+
|
|
774
|
+
expect( tooltip.labelFormatter( new Date( 2024, 0, 3, 6 ).getTime(), 0, [] ) ).toMatch(
|
|
775
|
+
/^January 3, 2024 at 6\sAM$/
|
|
776
|
+
);
|
|
777
|
+
} );
|
|
778
|
+
|
|
779
|
+
test( 'adds the hour for sub-daily buckets', () => {
|
|
780
|
+
const { tooltip } = optionsFor( [
|
|
781
|
+
{
|
|
782
|
+
label: 'Series A',
|
|
783
|
+
data: Array.from( { length: 12 }, ( _, i ) => ( {
|
|
784
|
+
date: new Date( 2024, 0, 1, i ),
|
|
785
|
+
value: 10 + i,
|
|
786
|
+
} ) ),
|
|
787
|
+
options: {},
|
|
788
|
+
},
|
|
789
|
+
] );
|
|
790
|
+
|
|
791
|
+
expect( tooltip.labelFormatter( new Date( 2024, 0, 1, 6 ).getTime(), 0, [] ) ).toMatch(
|
|
792
|
+
/^January 1, 2024 at 6\sAM$/
|
|
793
|
+
);
|
|
794
|
+
} );
|
|
795
|
+
|
|
796
|
+
test( 'adds the hour when tickResolution declares the buckets sub-daily', () => {
|
|
797
|
+
const { tooltip } = optionsFor(
|
|
798
|
+
[
|
|
799
|
+
{
|
|
800
|
+
label: 'Series A',
|
|
801
|
+
data: [ { date: new Date( 2024, 0, 1, 13 ), value: 10 } ],
|
|
802
|
+
options: {},
|
|
803
|
+
},
|
|
804
|
+
],
|
|
805
|
+
{ axis: { x: { tickResolution: 'hour' } } }
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
expect( tooltip.labelFormatter( new Date( 2024, 0, 1, 13 ).getTime(), 0, [] ) ).toMatch(
|
|
809
|
+
/^January 1, 2024 at 1\sPM$/
|
|
810
|
+
);
|
|
811
|
+
} );
|
|
812
|
+
|
|
813
|
+
test( 'names a declared weekly bucket as a week, not a single day', () => {
|
|
814
|
+
const { tooltip } = optionsFor(
|
|
815
|
+
[
|
|
816
|
+
{
|
|
817
|
+
label: 'Series A',
|
|
818
|
+
data: Array.from( { length: 8 }, ( _, i ) => ( {
|
|
819
|
+
date: new Date( 2026, 0, 5 + i * 7 ),
|
|
820
|
+
value: 10 + i,
|
|
821
|
+
} ) ),
|
|
822
|
+
options: {},
|
|
823
|
+
},
|
|
824
|
+
],
|
|
825
|
+
{ axis: { x: { tickResolution: 'week' } } }
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
expect( tooltip.labelFormatter( new Date( 2026, 0, 12 ).getTime(), 0, [] ) ).toBe(
|
|
829
|
+
'Week of January 12, 2026'
|
|
830
|
+
);
|
|
831
|
+
} );
|
|
832
|
+
|
|
833
|
+
test( 'follows tickResolution declared on the y axis of a horizontal chart', () => {
|
|
834
|
+
// The dates move to the y axis with the orientation, so the tooltip has
|
|
835
|
+
// to follow them the way the ticks do.
|
|
836
|
+
const { tooltip } = renderHook( () =>
|
|
837
|
+
useBarChartOptions(
|
|
838
|
+
[
|
|
839
|
+
{
|
|
840
|
+
label: 'Series A',
|
|
841
|
+
data: [ { date: new Date( 2024, 0, 1, 13 ), value: 10 } ],
|
|
842
|
+
options: {},
|
|
843
|
+
},
|
|
844
|
+
],
|
|
845
|
+
true,
|
|
846
|
+
{ axis: { y: { tickResolution: 'hour' } } }
|
|
847
|
+
)
|
|
848
|
+
).result.current;
|
|
849
|
+
|
|
850
|
+
expect( tooltip.labelFormatter( new Date( 2024, 0, 1, 13 ).getTime(), 0, [] ) ).toMatch(
|
|
851
|
+
/^January 1, 2024 at 1\sPM$/
|
|
852
|
+
);
|
|
853
|
+
} );
|
|
854
|
+
|
|
855
|
+
test( 'lets an explicit tickFormat override the resolution-derived label', () => {
|
|
856
|
+
const { tooltip } = optionsFor(
|
|
857
|
+
[
|
|
858
|
+
{
|
|
859
|
+
label: 'Series A',
|
|
860
|
+
data: Array.from( { length: 24 }, ( _, i ) => ( {
|
|
861
|
+
date: new Date( 2024, i, 1 ),
|
|
862
|
+
value: 10 + i,
|
|
863
|
+
} ) ),
|
|
864
|
+
options: {},
|
|
865
|
+
},
|
|
866
|
+
],
|
|
867
|
+
{
|
|
868
|
+
axis: {
|
|
869
|
+
x: {
|
|
870
|
+
tickResolution: 'month',
|
|
871
|
+
tickFormat: ( timestamp: number ) =>
|
|
872
|
+
new Date( timestamp ).toLocaleDateString( 'en-US', { dateStyle: 'short' } ),
|
|
873
|
+
},
|
|
874
|
+
},
|
|
875
|
+
}
|
|
876
|
+
);
|
|
877
|
+
|
|
878
|
+
expect( tooltip.labelFormatter( new Date( 2025, 2, 1 ).getTime(), 0, [] ) ).toBe( '3/1/25' );
|
|
879
|
+
} );
|
|
880
|
+
|
|
881
|
+
test( 'names the hovered bar bucket in the rendered tooltip', async () => {
|
|
882
|
+
const user = userEvent.setup();
|
|
883
|
+
renderWithTheme( {
|
|
884
|
+
withTooltips: true,
|
|
885
|
+
data: [
|
|
886
|
+
{
|
|
887
|
+
label: 'Series A',
|
|
888
|
+
data: Array.from( { length: 24 }, ( _, i ) => ( {
|
|
889
|
+
date: new Date( 2024, i, 1 ),
|
|
890
|
+
value: 10 + i,
|
|
891
|
+
} ) ),
|
|
892
|
+
options: {},
|
|
893
|
+
},
|
|
894
|
+
],
|
|
895
|
+
} );
|
|
896
|
+
|
|
897
|
+
screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
|
|
898
|
+
await user.keyboard( '{ArrowRight}' );
|
|
899
|
+
|
|
900
|
+
// The month tick reads "2024" here; the tooltip names the bucket itself.
|
|
901
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'January 2024: 10' );
|
|
902
|
+
} );
|
|
903
|
+
} );
|
|
904
|
+
|
|
221
905
|
describe( 'Pattern', () => {
|
|
222
906
|
test( 'renders with patterns', () => {
|
|
223
907
|
renderWithTheme( { withPatterns: true } );
|