@automattic/charts 4.1.1 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/TOKENS.md +1 -1
  3. package/dist/index.cjs +1335 -348
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +30 -1
  6. package/dist/index.d.cts +129 -8
  7. package/dist/index.d.ts +129 -8
  8. package/dist/index.js +1336 -351
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -8
  11. package/src/charts/area-chart/area-chart.tsx +42 -10
  12. package/src/charts/area-chart/private/overlays.tsx +1 -2
  13. package/src/charts/area-chart/private/validate-data.ts +2 -3
  14. package/src/charts/area-chart/test/area-chart.test.tsx +72 -1
  15. package/src/charts/bar-chart/bar-chart.tsx +137 -70
  16. package/src/charts/bar-chart/index.ts +1 -1
  17. package/src/charts/bar-chart/private/band-highlight.tsx +87 -0
  18. package/src/charts/bar-chart/private/band-scale.ts +13 -0
  19. package/src/charts/bar-chart/private/band-tooltip.tsx +138 -0
  20. package/src/charts/bar-chart/private/comparison-bars.tsx +5 -10
  21. package/src/charts/bar-chart/private/index.ts +3 -0
  22. package/src/charts/bar-chart/private/test/band-highlight.test.tsx +135 -0
  23. package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
  24. package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
  25. package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
  26. package/src/charts/bar-chart/private/use-bar-chart-options.ts +13 -40
  27. package/src/charts/bar-chart/private/value-domain.ts +90 -0
  28. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
  29. package/src/charts/bar-chart/test/bar-chart.test.tsx +1058 -19
  30. package/src/charts/bar-chart/types.ts +39 -0
  31. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -3
  32. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
  33. package/src/charts/conversion-funnel-chart/types.ts +4 -2
  34. package/src/charts/geo-chart/types.ts +4 -2
  35. package/src/charts/heatmap-chart/build-calendar-data.ts +5 -11
  36. package/src/charts/heatmap-chart/build-month-calendar-data.ts +124 -0
  37. package/src/charts/heatmap-chart/heatmap-chart.module.scss +32 -0
  38. package/src/charts/heatmap-chart/heatmap-chart.tsx +191 -114
  39. package/src/charts/heatmap-chart/index.ts +6 -0
  40. package/src/charts/heatmap-chart/private/civil-day.ts +26 -0
  41. package/src/charts/heatmap-chart/private/column-groups.ts +71 -0
  42. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +19 -3
  43. package/src/charts/heatmap-chart/private/index.ts +2 -0
  44. package/src/charts/heatmap-chart/private/keyboard-navigation.ts +201 -0
  45. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +36 -0
  46. package/src/charts/heatmap-chart/test/build-month-calendar-data.test.ts +194 -0
  47. package/src/charts/heatmap-chart/test/column-groups.test.ts +83 -0
  48. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +428 -2
  49. package/src/charts/heatmap-chart/test/keyboard-navigation.test.ts +25 -0
  50. package/src/charts/heatmap-chart/test/use-month-calendar-heatmap-data.test.tsx +90 -0
  51. package/src/charts/heatmap-chart/test/zoned-month-calendar-data.test.ts +23 -0
  52. package/src/charts/heatmap-chart/types.ts +59 -3
  53. package/src/charts/heatmap-chart/use-month-calendar-heatmap-data.ts +34 -0
  54. package/src/charts/leaderboard-chart/types.ts +13 -14
  55. package/src/charts/line-chart/line-chart.tsx +149 -36
  56. package/src/charts/line-chart/private/index.ts +1 -0
  57. package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
  58. package/src/charts/line-chart/private/line-chart-glyph.tsx +17 -3
  59. package/src/charts/line-chart/private/nearest-pointer-events.tsx +155 -0
  60. package/src/charts/line-chart/test/line-chart.test.tsx +604 -9
  61. package/src/charts/line-chart/types.ts +4 -3
  62. package/src/charts/private/readings.ts +34 -0
  63. package/src/charts/private/test/readings.test.ts +29 -0
  64. package/src/charts/private/test/whole-number-ticks.test.tsx +57 -0
  65. package/src/charts/private/time-axis-options.ts +1 -1
  66. package/src/charts/private/whole-number-ticks.tsx +79 -0
  67. package/src/charts/private/with-responsive/with-responsive.tsx +2 -2
  68. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +1 -3
  69. package/src/charts/private/x-zoom/x-zoom.tsx +1 -2
  70. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +124 -0
  71. package/src/components/legend/hooks/use-chart-legend-items.ts +60 -2
  72. package/src/components/legend/private/base-legend.tsx +37 -22
  73. package/src/components/legend/test/legend.test.tsx +84 -0
  74. package/src/components/legend/types.ts +2 -0
  75. package/src/components/legend/utils/index.ts +0 -1
  76. package/src/components/tooltip/accessible-tooltip.tsx +128 -41
  77. package/src/components/tooltip/base-tooltip.module.scss +6 -1
  78. package/src/components/tooltip/private/bounded-tooltip.tsx +12 -6
  79. package/src/components/tooltip/test/accessible-tooltip.test.tsx +128 -0
  80. package/src/components/tooltip/test/bounded-tooltip.test.tsx +17 -0
  81. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +7 -0
  82. package/src/components/tooltip/xy-chart-tooltip.tsx +9 -10
  83. package/src/hooks/test/use-chart-margin.test.tsx +35 -0
  84. package/src/hooks/test/use-element-size.test.tsx +13 -13
  85. package/src/hooks/use-chart-margin.tsx +7 -2
  86. package/src/hooks/use-xychart-theme.ts +1 -1
  87. package/src/index.ts +7 -1
  88. package/src/providers/chart-context/global-charts-provider.tsx +1 -1
  89. package/src/providers/chart-context/test/chart-context.test.tsx +4 -4
  90. package/src/providers/chart-context/themes.ts +1 -0
  91. package/src/styles/test/chart-scope.test.ts +1 -1
  92. package/src/types.ts +14 -13
  93. package/src/utils/format-metric-value.ts +4 -4
  94. package/src/utils/get-edge-tick-widths.ts +1 -1
  95. package/src/utils/get-styles.ts +1 -1
  96. package/src/visx/types.ts +7 -10
  97. package/src/components/legend/utils/label-transform-factory.ts +0 -24
@@ -1,14 +1,16 @@
1
1
  /* eslint-disable react/jsx-no-bind */
2
2
 
3
- import { render, screen, waitFor, within, act } from '@testing-library/react';
3
+ import { render, screen, waitFor, within, act, fireEvent } from '@testing-library/react';
4
4
  import userEvent from '@testing-library/user-event';
5
5
  import { GlyphDiamond } from '@visx/glyph';
6
- import { createElement, createRef } from 'react';
6
+ import { createElement, createRef, type ComponentProps } from 'react';
7
7
  import { GlobalChartsProvider, defaultTheme } from '../../../providers';
8
8
  import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context';
9
9
  import LineChart, { LineChartUnresponsive } from '../line-chart';
10
10
  import type { GlobalChartsContextValue } from '../../../providers/chart-context/types';
11
+ import type { DataPointDate, SeriesData } from '../../../types';
11
12
  import type { ChartInstanceRef } from '../../private/chart-instance-context';
13
+ import type { RenderLineGlyphProps } from '../types';
12
14
 
13
15
  // Mock useElementSize to return non-zero dimensions in jsdom so charts render
14
16
  const mockRefCallback = jest.fn();
@@ -254,18 +256,300 @@ describe( 'LineChart', () => {
254
256
  expect( screen.getByRole( 'grid', { name: /line chart/i } ) ).toBeInTheDocument();
255
257
  } );
256
258
 
257
- test( 'handles null or undefined values', () => {
259
+ test( 'renders a bucket with no reading instead of failing the whole chart', () => {
258
260
  renderWithTheme( {
259
261
  data: [
260
262
  {
261
263
  label: 'Series A',
262
264
  data: [
263
- { date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
264
- {
265
- date: new Date( '2024-01-02' ),
266
- value: undefined as number | undefined,
267
- label: 'Jan 2',
268
- },
265
+ { date: new Date( '2024-01-01' ), value: null as number | null },
266
+ { date: new Date( '2024-01-02' ), value: 20 },
267
+ ],
268
+ },
269
+ ],
270
+ } );
271
+ expect( screen.queryByText( /invalid data/i ) ).not.toBeInTheDocument();
272
+ expect( screen.getByRole( 'grid', { name: /line chart/i } ) ).toBeInTheDocument();
273
+ } );
274
+
275
+ test( 'tooltip reads No data for a bucket with no reading', async () => {
276
+ const user = userEvent.setup();
277
+ renderWithTheme( {
278
+ data: [
279
+ {
280
+ label: 'Series A',
281
+ data: [
282
+ { date: new Date( '2024-01-01' ), value: null as number | null },
283
+ { date: new Date( '2024-01-02' ), value: 20 },
284
+ ],
285
+ },
286
+ ],
287
+ } );
288
+
289
+ screen.getByRole( 'grid', { name: /line chart/i } ).focus();
290
+ await user.keyboard( '{ArrowRight}' );
291
+
292
+ const tooltip = screen.getByTestId( 'chart-tooltip-0' );
293
+ expect( tooltip ).toHaveTextContent( 'No data' );
294
+ expect( tooltip ).not.toHaveTextContent( 'Series A:0' );
295
+ } );
296
+
297
+ test( 'keeps a y axis when every visible bucket has no reading', () => {
298
+ renderWithTheme( {
299
+ data: [
300
+ {
301
+ label: 'Series A',
302
+ data: [
303
+ { date: new Date( '2024-01-01' ), value: null as number | null },
304
+ { date: new Date( '2024-01-02' ), value: null as number | null },
305
+ { date: new Date( '2024-01-03' ), value: null as number | null },
306
+ ],
307
+ },
308
+ ],
309
+ } );
310
+
311
+ expect( screen.queryByText( /invalid data/i ) ).not.toBeInTheDocument();
312
+
313
+ const chart = screen.getByRole( 'grid', { name: /line chart/i } );
314
+ const ticks = within( chart )
315
+ .getAllByText( /^-?[\d.,]+$/ )
316
+ .map( el => el.textContent ?? '' );
317
+ expect( ticks.length ).toBeGreaterThan( 0 );
318
+ ticks.forEach( tick => {
319
+ expect( Number.isFinite( Number( tick.replace( /,/g, '' ) ) ) ).toBe( true );
320
+ } );
321
+ } );
322
+
323
+ test( 'labels a whole-number range smaller than the tick count once per whole number', () => {
324
+ renderWithTheme( {
325
+ data: [
326
+ {
327
+ label: 'Series A',
328
+ data: [ 0, 1, 1, 0 ].map( ( value, i ) => ( {
329
+ date: new Date( 2024, i + 2, 1 ),
330
+ value,
331
+ } ) ),
332
+ },
333
+ ],
334
+ } );
335
+
336
+ const chart = screen.getByRole( 'grid', { name: /line chart/i } );
337
+ const ticks = within( chart )
338
+ .getAllByText( /^-?[\d.,]+$/ )
339
+ .map( el => el.textContent );
340
+ expect( ticks.sort() ).toEqual( [ '0', '1' ] );
341
+ } );
342
+
343
+ test( 'ignores a hidden fractional series when checking for whole numbers', () => {
344
+ renderWithTheme( {
345
+ defaultHiddenSeries: [ 'Series B' ],
346
+ data: [
347
+ {
348
+ label: 'Series A',
349
+ data: [ 0, 1, 1, 0 ].map( ( value, i ) => ( {
350
+ date: new Date( 2024, i + 2, 1 ),
351
+ value,
352
+ } ) ),
353
+ },
354
+ {
355
+ label: 'Series B',
356
+ data: [ 0.5, 0.5, 0.5, 0.5 ].map( ( value, i ) => ( {
357
+ date: new Date( 2024, i + 2, 1 ),
358
+ value,
359
+ } ) ),
360
+ },
361
+ ],
362
+ } );
363
+
364
+ const chart = screen.getByRole( 'grid', { name: /line chart/i } );
365
+ const ticks = within( chart )
366
+ .getAllByText( /^-?[\d.,]+$/ )
367
+ .map( el => el.textContent );
368
+ expect( ticks.sort() ).toEqual( [ '0', '1' ] );
369
+ } );
370
+
371
+ test( 'keeps fractional ticks when the data has fractions', () => {
372
+ renderWithTheme( {
373
+ data: [
374
+ {
375
+ label: 'Series A',
376
+ data: [ 0, 0.5, 1 ].map( ( value, i ) => ( {
377
+ date: new Date( 2024, i + 2, 1 ),
378
+ value,
379
+ } ) ),
380
+ },
381
+ ],
382
+ } );
383
+
384
+ const chart = screen.getByRole( 'grid', { name: /line chart/i } );
385
+ expect( within( chart ).getAllByText( /^-?[\d.,]+$/ ).length ).toBeGreaterThan( 2 );
386
+ } );
387
+
388
+ test( "keeps a caller's y tickValues", () => {
389
+ renderWithTheme( {
390
+ data: [
391
+ {
392
+ label: 'Series A',
393
+ data: [ 0, 1 ].map( ( value, i ) => ( { date: new Date( 2024, i + 2, 1 ), value } ) ),
394
+ },
395
+ ],
396
+ options: { axis: { y: { tickValues: [ 0, 0.5, 1 ] } } },
397
+ } );
398
+
399
+ const chart = screen.getByRole( 'grid', { name: /line chart/i } );
400
+ expect( within( chart ).getAllByText( /^-?[\d.,]+$/ ) ).toHaveLength( 3 );
401
+ } );
402
+
403
+ test( 'keeps every tick on a y domain the caller pinned', () => {
404
+ renderWithTheme( {
405
+ data: [
406
+ {
407
+ label: 'Series A',
408
+ data: [ 0, 0, 0 ].map( ( value, i ) => ( {
409
+ date: new Date( 2024, i + 2, 1 ),
410
+ value,
411
+ } ) ),
412
+ },
413
+ ],
414
+ options: {
415
+ yScale: { domain: [ 0, 1 ] },
416
+ axis: { y: { tickFormat: ( value: number ) => `${ Math.round( value * 100 ) }%` } },
417
+ },
418
+ } );
419
+
420
+ const chart = screen.getByRole( 'grid', { name: /line chart/i } );
421
+ expect( within( chart ).getAllByText( /^\d+%$/ ) ).toHaveLength( 6 );
422
+ } );
423
+
424
+ test( 'keeps a positive y domain on a log scale when every visible bucket has no reading', () => {
425
+ const ref = createRef< ChartInstanceRef >();
426
+
427
+ renderUnwrappedWithTheme(
428
+ {
429
+ options: { yScale: { type: 'log' } },
430
+ data: [
431
+ {
432
+ label: 'Series A',
433
+ data: [
434
+ { date: new Date( '2024-01-01' ), value: null as number | null },
435
+ { date: new Date( '2024-01-02' ), value: null as number | null },
436
+ ],
437
+ },
438
+ ],
439
+ },
440
+ 'default',
441
+ ref
442
+ );
443
+
444
+ const domain = (
445
+ ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
446
+ )?.domain();
447
+
448
+ expect( domain ).toEqual( [ 1, 10 ] );
449
+ } );
450
+
451
+ test.each( [
452
+ [ 'a flat reading beside buckets with no reading', [ null, null, 1, 1 ], [ 0, 1 ] ],
453
+ [ 'a flat positive series', [ 5, 5, 5 ], [ 0, 5 ] ],
454
+ [ 'a flat negative series', [ -3, -3 ], [ -3, 0 ] ],
455
+ [ 'an all-zero series', [ 0, 0 ], [ 0, 1 ] ],
456
+ ] )( 'starts the y domain at zero for %s', ( _name, values, expected ) => {
457
+ const ref = createRef< ChartInstanceRef >();
458
+
459
+ renderUnwrappedWithTheme(
460
+ {
461
+ data: [
462
+ {
463
+ label: 'Series A',
464
+ data: values.map( ( value, index ) => ( {
465
+ date: new Date( 2024, 0, index + 1 ),
466
+ value: value as number | null,
467
+ } ) ),
468
+ },
469
+ ],
470
+ },
471
+ 'default',
472
+ ref
473
+ );
474
+
475
+ const domain = (
476
+ ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
477
+ )?.domain();
478
+
479
+ expect( domain ).toEqual( expected );
480
+ } );
481
+
482
+ test( 'keeps the y domain off zero for a series that varies', () => {
483
+ const ref = createRef< ChartInstanceRef >();
484
+
485
+ renderUnwrappedWithTheme(
486
+ {
487
+ data: [
488
+ {
489
+ label: 'Series A',
490
+ data: [
491
+ { date: new Date( '2024-01-01' ), value: 50 },
492
+ { date: new Date( '2024-01-02' ), value: 60 },
493
+ ],
494
+ },
495
+ ],
496
+ },
497
+ 'default',
498
+ ref
499
+ );
500
+
501
+ const domain = (
502
+ ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
503
+ )?.domain();
504
+
505
+ expect( Math.min( ...( domain ?? [] ) ) ).toBeGreaterThan( 0 );
506
+ } );
507
+
508
+ test( 'pins the value axis to a hidden series with real values, not the empty-domain fallback, when rescaleYOnVisibilityChange is false', () => {
509
+ const ref = createRef< ChartInstanceRef >();
510
+
511
+ renderUnwrappedWithTheme(
512
+ {
513
+ rescaleYOnVisibilityChange: false,
514
+ defaultHiddenSeries: [ 'Series A' ],
515
+ data: [
516
+ {
517
+ label: 'Series A',
518
+ data: [
519
+ { date: new Date( '2024-01-01' ), value: 10 },
520
+ { date: new Date( '2024-01-02' ), value: 20 },
521
+ ],
522
+ },
523
+ {
524
+ label: 'Series B',
525
+ data: [
526
+ { date: new Date( '2024-01-01' ), value: null as number | null },
527
+ { date: new Date( '2024-01-02' ), value: null as number | null },
528
+ ],
529
+ },
530
+ ],
531
+ },
532
+ 'default',
533
+ ref
534
+ );
535
+
536
+ const domain = (
537
+ ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
538
+ )?.domain();
539
+
540
+ expect( domain ).toBeDefined();
541
+ expect( domain ).not.toEqual( [ 0, 1 ] );
542
+ expect( Math.max( ...( domain ?? [] ) ) ).toBeGreaterThan( 1 );
543
+ } );
544
+
545
+ test( 'still rejects undefined values', () => {
546
+ renderWithTheme( {
547
+ data: [
548
+ {
549
+ label: 'Series A',
550
+ data: [
551
+ { date: new Date( '2024-01-01' ), value: undefined as number | undefined },
552
+ { date: new Date( '2024-01-02' ), value: 20 },
269
553
  ],
270
554
  },
271
555
  ],
@@ -273,6 +557,28 @@ describe( 'LineChart', () => {
273
557
  expect( screen.getByText( /invalid data/i ) ).toBeInTheDocument();
274
558
  } );
275
559
 
560
+ test( 'breaks the line at a bucket with no reading', () => {
561
+ renderWithTheme( {
562
+ data: [
563
+ {
564
+ label: 'Series A',
565
+ data: [
566
+ { date: new Date( '2024-01-01' ), value: 10 },
567
+ { date: new Date( '2024-01-02' ), value: null as number | null },
568
+ { date: new Date( '2024-01-03' ), value: 20 },
569
+ { date: new Date( '2024-01-04' ), value: 30 },
570
+ ],
571
+ },
572
+ ],
573
+ } );
574
+
575
+ const grid = screen.getByRole( 'grid', { name: /line chart/i } );
576
+ // eslint-disable-next-line testing-library/no-node-access -- visx's stroke is a <path class="visx-line">; the axis and grid lines are plain <line> elements sharing that class.
577
+ const linePath = grid.querySelector( 'path.visx-line' );
578
+ const gapCount = linePath?.getAttribute( 'd' )?.match( /M/g )?.length;
579
+ expect( gapCount ).toBe( 2 );
580
+ } );
581
+
276
582
  test( 'handles invalid date values', () => {
277
583
  renderWithTheme( {
278
584
  data: [
@@ -1042,6 +1348,42 @@ describe( 'LineChart', () => {
1042
1348
  const endGlyphs = screen.getAllByTestId( /end-glyph/i );
1043
1349
  expect( endGlyphs ).toHaveLength( 1 );
1044
1350
  } );
1351
+
1352
+ test( 'puts the start and end glyphs on the first and last real reading', () => {
1353
+ const recorded: { position?: 'start' | 'end'; value: number | null; y: number }[] = [];
1354
+
1355
+ renderWithTheme( {
1356
+ withStartGlyphs: true,
1357
+ withEndGlyphs: true,
1358
+ renderGlyph: ( props: RenderLineGlyphProps< DataPointDate > ) => {
1359
+ recorded.push( {
1360
+ position: props.position,
1361
+ value: props.datum.value ?? null,
1362
+ y: props.y,
1363
+ } );
1364
+ return null;
1365
+ },
1366
+ data: [
1367
+ {
1368
+ label: 'Series A',
1369
+ data: [
1370
+ { date: new Date( '2024-01-01' ), value: null as number | null },
1371
+ { date: new Date( '2024-01-02' ), value: 10 },
1372
+ { date: new Date( '2024-01-03' ), value: 20 },
1373
+ { date: new Date( '2024-01-04' ), value: null as number | null },
1374
+ ],
1375
+ },
1376
+ ],
1377
+ } );
1378
+
1379
+ const start = recorded.find( entry => entry.position === 'start' );
1380
+ const end = recorded.find( entry => entry.position === 'end' );
1381
+
1382
+ expect( start?.value ).toBe( 10 );
1383
+ expect( end?.value ).toBe( 20 );
1384
+ expect( Number.isFinite( start?.y ) ).toBe( true );
1385
+ expect( Number.isFinite( end?.y ) ).toBe( true );
1386
+ } );
1045
1387
  } );
1046
1388
 
1047
1389
  describe( 'Legend Glyphs', () => {
@@ -1713,6 +2055,127 @@ describe( 'LineChart', () => {
1713
2055
  } );
1714
2056
  } );
1715
2057
 
2058
+ describe( 'pointer events', () => {
2059
+ let screenTransform: PropertyDescriptor | undefined;
2060
+
2061
+ beforeAll( () => {
2062
+ screenTransform = Object.getOwnPropertyDescriptor( SVGElement.prototype, 'getScreenCTM' );
2063
+ // jsdom lacks the SVG transform used by visx to convert pointer coordinates.
2064
+ Object.defineProperty( SVGElement.prototype, 'getScreenCTM', {
2065
+ configurable: true,
2066
+ value: () => null,
2067
+ } );
2068
+ } );
2069
+
2070
+ afterAll( () => {
2071
+ if ( screenTransform ) {
2072
+ Object.defineProperty( SVGElement.prototype, 'getScreenCTM', screenTransform );
2073
+ } else {
2074
+ Reflect.deleteProperty( SVGElement.prototype, 'getScreenCTM' );
2075
+ }
2076
+ } );
2077
+
2078
+ const pointer = ( type: string, clientX: number, clientY: number ) => {
2079
+ // visx owns the pointer capture rect and does not expose an attribute prop for it.
2080
+ // eslint-disable-next-line testing-library/no-node-access
2081
+ const target = screen.getByRole( 'grid' ).querySelector( 'svg > rect[fill="transparent"]' );
2082
+ fireEvent( target, new MouseEvent( type, { bubbles: true, clientX, clientY } ) );
2083
+ };
2084
+
2085
+ const renderWithHandlers = ( data: SeriesData[], props = {} ) => {
2086
+ const handlers = {
2087
+ onPointerDown: jest.fn(),
2088
+ onPointerMove: jest.fn(),
2089
+ onPointerUp: jest.fn(),
2090
+ };
2091
+ mockUseXZoom.mockImplementation( () => ( { ...passthroughZoom(), handlers } ) );
2092
+ const ref = createRef< ChartInstanceRef >();
2093
+ renderUnwrappedWithTheme( { data, ...props }, 'default', ref );
2094
+ const xScale = ref.current?.getScales()?.xScale as ( date: Date ) => number;
2095
+ const yScale = ref.current?.getScales()?.yScale as ( value: number ) => number;
2096
+ return { handlers, xScale, yScale };
2097
+ };
2098
+
2099
+ const series = ( label: string, values: ( number | null )[] ) => ( {
2100
+ label,
2101
+ options: {},
2102
+ data: values.map( ( value, index ) => ( {
2103
+ date: new Date( 2024, 0, index + 1 ),
2104
+ value,
2105
+ } ) ),
2106
+ } );
2107
+
2108
+ test( 'reports a bucket with no reading on pointer down, move and up', () => {
2109
+ const data = [ series( 'Series A', [ null, 10, 20 ] ) ];
2110
+ const { handlers, xScale } = renderWithHandlers( data );
2111
+ const x = xScale( data[ 0 ].data[ 0 ].date );
2112
+
2113
+ for ( const [ type, handler ] of [
2114
+ [ 'pointerdown', handlers.onPointerDown ],
2115
+ [ 'pointermove', handlers.onPointerMove ],
2116
+ [ 'pointerup', handlers.onPointerUp ],
2117
+ ] as const ) {
2118
+ pointer( type, x, 150 );
2119
+ expect( handler ).toHaveBeenCalledTimes( 1 );
2120
+ expect( handler ).toHaveBeenLastCalledWith(
2121
+ expect.objectContaining( { key: 'Series A', index: 0, datum: data[ 0 ].data[ 0 ] } )
2122
+ );
2123
+ }
2124
+ } );
2125
+
2126
+ test( 'prefers a series with a reading over one without at the same bucket', () => {
2127
+ const data = [ series( 'Series A', [ null, 10 ] ), series( 'Series B', [ 5, 10 ] ) ];
2128
+ const { handlers, xScale, yScale } = renderWithHandlers( data );
2129
+
2130
+ pointer( 'pointerdown', xScale( data[ 0 ].data[ 0 ].date ), yScale( 10 ) );
2131
+
2132
+ expect( handlers.onPointerDown ).toHaveBeenCalledTimes( 1 );
2133
+ expect( handlers.onPointerDown ).toHaveBeenLastCalledWith(
2134
+ expect.objectContaining( { key: 'Series B', index: 0 } )
2135
+ );
2136
+ } );
2137
+
2138
+ test( 'reports nothing for a reading the log scale cannot place', () => {
2139
+ const data = [ series( 'Series A', [ 0, 10 ] ) ];
2140
+ const { handlers, xScale } = renderWithHandlers( data, {
2141
+ options: { yScale: { type: 'log', domain: [ 1, 100 ] } },
2142
+ } );
2143
+
2144
+ pointer( 'pointerdown', xScale( data[ 0 ].data[ 0 ].date ), 150 );
2145
+
2146
+ expect( handlers.onPointerDown ).not.toHaveBeenCalled();
2147
+ } );
2148
+
2149
+ test( 'reports the nearest bucket in a long series', () => {
2150
+ const data = [
2151
+ series(
2152
+ 'Series A',
2153
+ Array.from( { length: 60 }, ( _, i ) => i )
2154
+ ),
2155
+ ];
2156
+ const { handlers, xScale } = renderWithHandlers( data );
2157
+ const between =
2158
+ ( xScale( data[ 0 ].data[ 37 ].date ) * 2 + xScale( data[ 0 ].data[ 38 ].date ) ) / 3;
2159
+
2160
+ pointer( 'pointerdown', between, 150 );
2161
+
2162
+ expect( handlers.onPointerDown ).toHaveBeenLastCalledWith(
2163
+ expect.objectContaining( { index: 37, datum: data[ 0 ].data[ 37 ] } )
2164
+ );
2165
+ } );
2166
+
2167
+ test( 'reports the series nearest the pointer when every series has a reading', () => {
2168
+ const data = [ series( 'Series A', [ 10, 10 ] ), series( 'Series B', [ 20, 20 ] ) ];
2169
+ const { handlers, xScale, yScale } = renderWithHandlers( data );
2170
+
2171
+ pointer( 'pointerdown', xScale( data[ 0 ].data[ 1 ].date ), yScale( 11 ) );
2172
+
2173
+ expect( handlers.onPointerDown ).toHaveBeenLastCalledWith(
2174
+ expect.objectContaining( { key: 'Series A', index: 1 } )
2175
+ );
2176
+ } );
2177
+ } );
2178
+
1716
2179
  // The line is not animated, so it clips only while actually zoomed.
1717
2180
  test( 'clips the series to the plot when zoomed', () => {
1718
2181
  mockUseXZoom.mockImplementation( () => ( {
@@ -1729,6 +2192,138 @@ describe( 'LineChart', () => {
1729
2192
  );
1730
2193
  } );
1731
2194
 
2195
+ describe( 'Value axis baseline', () => {
2196
+ const steadySeries = [
2197
+ {
2198
+ label: 'Views',
2199
+ data: [ 921, 989, 954, 924, 967, 933, 978 ].map( ( value, day ) => ( {
2200
+ date: new Date( 2024, 0, day + 1 ),
2201
+ value,
2202
+ label: `Jan ${ day + 1 }`,
2203
+ } ) ),
2204
+ options: {},
2205
+ },
2206
+ ];
2207
+ const twoSeries = [
2208
+ {
2209
+ label: 'Views',
2210
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
2211
+ options: {},
2212
+ },
2213
+ {
2214
+ label: 'Visitors',
2215
+ data: [ { date: new Date( '2024-01-01' ), value: 200, label: 'Jan 1' } ],
2216
+ options: {},
2217
+ },
2218
+ ];
2219
+
2220
+ const renderForDomain = (
2221
+ props: Partial< ComponentProps< typeof LineChartUnresponsive > >
2222
+ ) => {
2223
+ let context: GlobalChartsContextValue | undefined;
2224
+ const Grab = () => {
2225
+ context = useGlobalChartsContext();
2226
+ return null;
2227
+ };
2228
+ const ref = createRef< ChartInstanceRef >();
2229
+
2230
+ render(
2231
+ <GlobalChartsProvider>
2232
+ <Grab />
2233
+ <LineChartUnresponsive
2234
+ width={ 500 }
2235
+ height={ 300 }
2236
+ withGradientFill={ false }
2237
+ chartId="line-baseline"
2238
+ ref={ ref }
2239
+ data={ steadySeries }
2240
+ { ...props }
2241
+ />
2242
+ </GlobalChartsProvider>
2243
+ );
2244
+
2245
+ return {
2246
+ domain: () =>
2247
+ ( ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined )?.domain(),
2248
+ hide: ( label: string ) =>
2249
+ act( () => context?.toggleSeriesVisibility( 'line-baseline', label ) ),
2250
+ };
2251
+ };
2252
+
2253
+ it( 'fits the axis to the data by default', () => {
2254
+ expect( renderForDomain( {} ).domain() ).toEqual( [ 920, 990 ] );
2255
+ } );
2256
+
2257
+ it( 'rounds the top of a zero-based axis from zero, not from the data floor', () => {
2258
+ expect( renderForDomain( { options: { yScale: { zero: true } } } ).domain() ).toEqual( [
2259
+ 0, 1000,
2260
+ ] );
2261
+ } );
2262
+
2263
+ it( 'leaves a log scale to fit the data, since it cannot hold zero', () => {
2264
+ const { domain } = renderForDomain( { options: { yScale: { type: 'log', zero: true } } } );
2265
+ expect( domain() ).toEqual( [ 100, 1000 ] );
2266
+ } );
2267
+
2268
+ it( 'extends a zero-based axis below zero when a series goes negative', () => {
2269
+ const { domain } = renderForDomain( {
2270
+ options: { yScale: { zero: true } },
2271
+ data: [
2272
+ {
2273
+ label: 'Net change',
2274
+ data: [
2275
+ { date: new Date( '2024-01-01' ), value: -15, label: 'Jan 1' },
2276
+ { date: new Date( '2024-01-02' ), value: 30, label: 'Jan 2' },
2277
+ ],
2278
+ options: {},
2279
+ },
2280
+ ],
2281
+ } );
2282
+ expect( domain() ).toEqual( [ -15, 30 ] );
2283
+ } );
2284
+
2285
+ it( 'gives an all-zero series an axis from 0 to 1 instead of a spanless one', () => {
2286
+ const { domain } = renderForDomain( {
2287
+ options: { yScale: { zero: true } },
2288
+ data: [
2289
+ {
2290
+ label: 'Views',
2291
+ data: [
2292
+ { date: new Date( '2024-01-01' ), value: 0, label: 'Jan 1' },
2293
+ { date: new Date( '2024-01-02' ), value: 0, label: 'Jan 2' },
2294
+ ],
2295
+ options: {},
2296
+ },
2297
+ ],
2298
+ } );
2299
+ expect( domain() ).toEqual( [ 0, 1 ] );
2300
+ } );
2301
+
2302
+ it( 'rescales a zero-based axis to the visible series', () => {
2303
+ const { domain, hide } = renderForDomain( {
2304
+ options: { yScale: { zero: true } },
2305
+ data: twoSeries,
2306
+ } );
2307
+ expect( domain() ).toEqual( [ 0, 200 ] );
2308
+
2309
+ hide( 'Visitors' );
2310
+
2311
+ expect( domain() ).toEqual( [ 0, 20 ] );
2312
+ } );
2313
+
2314
+ it( 'pins a zero-based axis when rescaleYOnVisibilityChange is false', () => {
2315
+ const { domain, hide } = renderForDomain( {
2316
+ options: { yScale: { zero: true } },
2317
+ rescaleYOnVisibilityChange: false,
2318
+ data: twoSeries,
2319
+ } );
2320
+
2321
+ hide( 'Visitors' );
2322
+
2323
+ expect( domain() ).toEqual( [ 0, 200 ] );
2324
+ } );
2325
+ } );
2326
+
1732
2327
  describe( 'Legend group collapsing', () => {
1733
2328
  const comparisonPair = [
1734
2329
  {
@@ -8,7 +8,7 @@ import type {
8
8
  AnnotationStyles,
9
9
  DataPoint,
10
10
  } from '../../types';
11
- import type { CrosshairStyle, RenderTooltipParams, XyChartTooltipProps } from '../../visx/types';
11
+ import type { CrosshairStyle, RenderTooltipParams } from '../../visx/types';
12
12
  import type { GlyphProps } from '@visx/xychart';
13
13
  import type { ReactNode, SVGProps, FC, CSSProperties } from 'react';
14
14
 
@@ -45,7 +45,7 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] >, SeriesVi
45
45
  * Place the panel below the x-axis tick labels with a pointer at the datum x; horizontal bounds still apply.
46
46
  * @default 'auto'
47
47
  */
48
- tooltipPlacement?: XyChartTooltipProps< DataPointDate >[ 'tooltipPlacement' ];
48
+ tooltipPlacement?: 'auto' | 'below-axis';
49
49
  /**
50
50
  * Inline container styles; see Below-Axis Tooltips in stories/index.docs.mdx for content color overrides.
51
51
  */
@@ -81,7 +81,8 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] >, SeriesVi
81
81
 
82
82
  export type TooltipDatum = {
83
83
  key: string;
84
- value: number;
84
+ /** The series' reading, or null when the bucket has none. */
85
+ value: number | null;
85
86
  };
86
87
 
87
88
  export type LineChartGlyphProps = {