@automattic/charts 4.2.0 → 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 (45) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/index.cjs +470 -121
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +23 -2
  5. package/dist/index.d.ts +23 -2
  6. package/dist/index.js +471 -122
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/charts/area-chart/area-chart.tsx +42 -10
  10. package/src/charts/area-chart/private/validate-data.ts +2 -3
  11. package/src/charts/area-chart/test/area-chart.test.tsx +72 -1
  12. package/src/charts/bar-chart/bar-chart.tsx +100 -83
  13. package/src/charts/bar-chart/index.ts +1 -1
  14. package/src/charts/bar-chart/private/band-highlight.tsx +3 -1
  15. package/src/charts/bar-chart/private/band-tooltip.tsx +1 -1
  16. package/src/charts/bar-chart/private/index.ts +1 -0
  17. package/src/charts/bar-chart/private/test/band-highlight.test.tsx +28 -2
  18. package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
  19. package/src/charts/bar-chart/private/use-bar-chart-options.ts +12 -39
  20. package/src/charts/bar-chart/private/value-domain.ts +90 -0
  21. package/src/charts/bar-chart/test/bar-chart.test.tsx +484 -9
  22. package/src/charts/bar-chart/types.ts +39 -0
  23. package/src/charts/line-chart/line-chart.tsx +148 -35
  24. package/src/charts/line-chart/private/index.ts +1 -0
  25. package/src/charts/line-chart/private/line-chart-glyph.tsx +17 -3
  26. package/src/charts/line-chart/private/nearest-pointer-events.tsx +155 -0
  27. package/src/charts/line-chart/test/line-chart.test.tsx +604 -9
  28. package/src/charts/line-chart/types.ts +2 -1
  29. package/src/charts/private/readings.ts +34 -0
  30. package/src/charts/private/test/readings.test.ts +29 -0
  31. package/src/charts/private/test/whole-number-ticks.test.tsx +57 -0
  32. package/src/charts/private/whole-number-ticks.tsx +79 -0
  33. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +124 -0
  34. package/src/components/legend/hooks/use-chart-legend-items.ts +60 -2
  35. package/src/components/legend/private/base-legend.tsx +36 -21
  36. package/src/components/legend/test/legend.test.tsx +84 -0
  37. package/src/components/legend/types.ts +2 -0
  38. package/src/components/legend/utils/index.ts +0 -1
  39. package/src/components/tooltip/accessible-tooltip.tsx +1 -1
  40. package/src/hooks/test/use-chart-margin.test.tsx +35 -0
  41. package/src/hooks/use-chart-margin.tsx +7 -2
  42. package/src/providers/chart-context/global-charts-provider.tsx +1 -1
  43. package/src/providers/chart-context/test/chart-context.test.tsx +4 -4
  44. package/src/types.ts +8 -0
  45. package/src/components/legend/utils/label-transform-factory.ts +0 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "description": "Display charts within Automattic products.",
5
5
  "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
6
6
  "bugs": {
@@ -37,6 +37,7 @@ import { ChartLayout } from '../private/chart-layout';
37
37
  import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
38
38
  import { getCurveType } from '../private/time-axis';
39
39
  import { buildTimeAxisOptions } from '../private/time-axis-options';
40
+ import { hasOnlyWholeNumbers, WholeNumberTicks } from '../private/whole-number-ticks';
40
41
  import { withResponsive } from '../private/with-responsive';
41
42
  import { useXZoom, ZoomResetButton, ZoomSelectionRect, ZoomClip } from '../private/x-zoom';
42
43
  import plotStyles from '../private/xy-plot/xy-plot.module.scss';
@@ -142,6 +143,15 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
142
143
  [ seriesWithVisibility ]
143
144
  );
144
145
 
146
+ // A normalized stack (expand/wiggle/silhouette) turns whole-number data into
147
+ // fractions, so the filter is skipped for every offset but 'none'.
148
+ const hasWholeNumberValues = useMemo(
149
+ () =>
150
+ ( ! stacked || stackOffset === 'none' ) &&
151
+ hasOnlyWholeNumbers( dataSorted.filter( series => isSeriesVisible( series.label ) ) ),
152
+ [ dataSorted, isSeriesVisible, stacked, stackOffset ]
153
+ );
154
+
145
155
  const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
146
156
  selectedIndex,
147
157
  setSelectedIndex,
@@ -250,8 +260,9 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
250
260
  withGlyph: false,
251
261
  glyphSize: 0,
252
262
  collapseGroups: legend.collapseGroups ?? false,
263
+ comparisonItem: legend.comparisonItem ?? false,
253
264
  } ),
254
- [ legend.collapseGroups ]
265
+ [ legend.collapseGroups, legend.comparisonItem ]
255
266
  );
256
267
  const legendItems = useChartLegendItems( dataSorted, legendOptions, legendShape );
257
268
 
@@ -438,15 +449,36 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
438
449
  { /* With every series hidden the value scale collapses, so the grid and axes
439
450
  are dropped while the empty state stands in — otherwise they render
440
451
  squished at the top. */ }
441
- { ! allSeriesHidden && gridVisibility !== 'none' && (
442
- <Grid columns={ false } numTicks={ 4 } />
443
- ) }
444
- { ! allSeriesHidden && chartOptions.axis.x.display && (
445
- <Axis { ...chartOptions.axis.x } />
446
- ) }
447
- { ! allSeriesHidden && chartOptions.axis.y.display && (
448
- <Axis { ...chartOptions.axis.y } />
449
- ) }
452
+ <WholeNumberTicks
453
+ axis="y"
454
+ numTicks={ chartOptions.axis.y.numTicks }
455
+ enabled={
456
+ hasWholeNumberValues &&
457
+ ! chartOptions.axis.y.tickValues &&
458
+ ! options?.yScale?.domain
459
+ }
460
+ >
461
+ { tickValues => (
462
+ <>
463
+ { ! allSeriesHidden && gridVisibility !== 'none' && (
464
+ <Grid
465
+ columns={ false }
466
+ numTicks={ chartOptions.axis.y.numTicks }
467
+ { ...{ tickValues: tickValues ?? chartOptions.axis.y.tickValues } }
468
+ />
469
+ ) }
470
+ { ! allSeriesHidden && chartOptions.axis.x.display && (
471
+ <Axis { ...chartOptions.axis.x } />
472
+ ) }
473
+ { ! allSeriesHidden && chartOptions.axis.y.display && (
474
+ <Axis
475
+ { ...chartOptions.axis.y }
476
+ { ...( tickValues ? { tickValues } : {} ) }
477
+ />
478
+ ) }
479
+ </>
480
+ ) }
481
+ </WholeNumberTicks>
450
482
 
451
483
  { allSeriesHidden ? (
452
484
  <SvgEmptyState
@@ -1,4 +1,5 @@
1
1
  import { __ } from '@wordpress/i18n';
2
+ import { isInvalidReading } from '../../private/readings';
2
3
  import type { DataPoint, DataPointDate, SeriesData } from '../../../types';
3
4
 
4
5
  /**
@@ -19,9 +20,7 @@ export const validateData = ( data: SeriesData[] ) => {
19
20
  const hasInvalidData = data.some( series =>
20
21
  series.data.some(
21
22
  ( point: DataPointDate | DataPoint ) =>
22
- isNaN( point.value as number ) ||
23
- point.value === null ||
24
- point.value === undefined ||
23
+ isInvalidReading( point.value, { allowMissing: false } ) ||
25
24
  ( 'date' in point && point.date && isNaN( point.date.getTime() ) )
26
25
  )
27
26
  );
@@ -1,4 +1,4 @@
1
- import { render, screen, act } from '@testing-library/react';
1
+ import { render, screen, within, act } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { createRef } from 'react';
4
4
  import { GlobalChartsProvider } from '../../../providers';
@@ -188,6 +188,77 @@ describe( 'AreaChart', () => {
188
188
  } );
189
189
  } );
190
190
 
191
+ describe( 'Y-Axis Ticks', () => {
192
+ test( 'labels a whole-number range smaller than the tick count once per whole number', () => {
193
+ renderWithProvider( {
194
+ stacked: false,
195
+ data: [
196
+ {
197
+ label: 'Series A',
198
+ data: [ 0, 1, 1, 0 ].map( ( value, i ) => ( {
199
+ date: new Date( 2024, i + 2, 1 ),
200
+ value,
201
+ } ) ),
202
+ },
203
+ ],
204
+ } );
205
+
206
+ const chart = screen.getByRole( 'grid', { name: /area chart/i } );
207
+ const ticks = within( chart )
208
+ .getAllByText( /^-?[\d.,]+$/ )
209
+ .map( el => el.textContent );
210
+ expect( ticks.sort() ).toEqual( [ '0', '1' ] );
211
+ } );
212
+
213
+ test( 'keeps every tick on a y domain the caller pinned', () => {
214
+ renderWithProvider( {
215
+ stacked: false,
216
+ data: [
217
+ {
218
+ label: 'Series A',
219
+ data: [ 0, 0, 0 ].map( ( value, i ) => ( {
220
+ date: new Date( 2024, i + 2, 1 ),
221
+ value,
222
+ } ) ),
223
+ },
224
+ ],
225
+ options: {
226
+ yScale: { domain: [ 0, 1 ] },
227
+ axis: { y: { tickFormat: ( value: number ) => `${ Math.round( value * 100 ) }%` } },
228
+ },
229
+ } );
230
+
231
+ const chart = screen.getByRole( 'grid', { name: /area chart/i } );
232
+ expect( within( chart ).getAllByText( /^\d+%$/ ) ).toHaveLength( 6 );
233
+ } );
234
+
235
+ test( 'keeps fractional ticks for a normalized stack even when the data is whole numbers', () => {
236
+ renderWithProvider( {
237
+ stacked: true,
238
+ stackOffset: 'expand',
239
+ data: [
240
+ {
241
+ label: 'Series A',
242
+ data: [ 0, 1, 1, 0 ].map( ( value, i ) => ( {
243
+ date: new Date( 2024, i + 2, 1 ),
244
+ value,
245
+ } ) ),
246
+ },
247
+ {
248
+ label: 'Series B',
249
+ data: [ 1, 0, 0, 1 ].map( ( value, i ) => ( {
250
+ date: new Date( 2024, i + 2, 1 ),
251
+ value,
252
+ } ) ),
253
+ },
254
+ ],
255
+ } );
256
+
257
+ const chart = screen.getByRole( 'grid', { name: /area chart/i } );
258
+ expect( within( chart ).getAllByText( /^-?[\d.,]+$/ ).length ).toBeGreaterThan( 2 );
259
+ } );
260
+ } );
261
+
191
262
  describe( 'Stacking', () => {
192
263
  test( 'is stacked by default', () => {
193
264
  renderWithProvider();
@@ -1,4 +1,3 @@
1
- import { formatNumber } from '@automattic/number-formatters';
2
1
  import { PatternLines, PatternCircles, PatternWaves, PatternHexagons } from '@visx/pattern';
3
2
  import { Axis, BarSeries, BarGroup, Grid, XYChart } from '@visx/xychart';
4
3
  import { __, sprintf } from '@wordpress/i18n';
@@ -22,10 +21,13 @@ import {
22
21
  } from '../../providers';
23
22
  import { useDefaultHiddenSeries } from '../../providers/chart-context/hooks/use-default-hidden-series';
24
23
  import { attachSubComponents } from '../../utils';
24
+ import { warnOnce } from '../../utils/warn-once';
25
25
  import { useChartChildren } from '../private/chart-composition';
26
26
  import { ChartInstanceContext } from '../private/chart-instance-context';
27
27
  import { ChartLayout } from '../private/chart-layout';
28
+ import { formatReading, isInvalidReading } from '../private/readings';
28
29
  import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
30
+ import { hasOnlyWholeNumbers, WholeNumberTicks } from '../private/whole-number-ticks';
29
31
  import { withResponsive } from '../private/with-responsive';
30
32
  import plotStyles from '../private/xy-plot/xy-plot.module.scss';
31
33
  import styles from './bar-chart.module.scss';
@@ -39,47 +41,17 @@ import {
39
41
  MAX_GROUP_PADDING,
40
42
  COMPARISON_TICK_GAP_FACTOR,
41
43
  BASE_BAND_PADDING_INNER,
44
+ countRenderedBars,
45
+ isBarRendered,
42
46
  } from './private';
43
47
  import type { ComparisonSeriesEntry } from './private';
44
- import type {
45
- BaseChartProps,
46
- DataPointDate,
47
- SeriesData,
48
- SeriesChartLegendConfig,
49
- SeriesVisibilityProps,
50
- Optional,
51
- } from '../../types';
48
+ import type { BarChartProps } from './types';
49
+ import type { DataPointDate, SeriesData, Optional } from '../../types';
52
50
  import type { RenderTooltipParams } from '../../visx/types';
53
51
  import type { ResponsiveConfig } from '../private/with-responsive';
54
- import type { FC, ReactNode, ComponentType } from 'react';
55
-
56
- export type BandHighlightSelection = {
57
- datum: DataPointDate;
58
- x: number;
59
- y: number;
60
- width: number;
61
- height: number;
62
- };
52
+ import type { FC, ComponentType } from 'react';
63
53
 
64
- export interface BarChartProps extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps {
65
- /**
66
- * Legend configuration. Supports `collapseGroups` on top of the shared options.
67
- */
68
- legend?: SeriesChartLegendConfig;
69
- renderTooltip?: ( params: RenderTooltipParams< DataPointDate > ) => ReactNode;
70
- /** Place the tooltip beside its category without vertical flipping. */
71
- tooltipPlacement?: 'auto' | 'beside';
72
- /** Tooltip top anchor in SVG coordinates; negative offsets are supported. */
73
- tooltipAnchorTop?: number;
74
- orientation?: 'horizontal' | 'vertical';
75
- withPatterns?: boolean;
76
- showZeroValues?: boolean;
77
- /** Highlight the active band across the plot when tooltips are enabled. */
78
- withBandHighlight?: boolean;
79
- /** Receive active band bounds in SVG coordinates, or null when dismissed. */
80
- onBandHighlightChange?: ( selection: BandHighlightSelection | null ) => void;
81
- children?: ReactNode;
82
- }
54
+ export type { BarChartProps, BandHighlightSelection } from './types';
83
55
 
84
56
  // Base props type with optional responsive properties
85
57
  type BarChartBaseProps = Optional< BarChartProps, 'width' | 'height' | 'size' >;
@@ -95,20 +67,18 @@ type BarChartResponsiveComponent = FC< BarChartBaseProps & ResponsiveConfig > &
95
67
 
96
68
  // Validation function similar to LineChart
97
69
  const validateData = ( data: SeriesData[] ) => {
98
- if ( ! data?.length ) return 'No data available';
70
+ if ( ! data?.length ) return __( 'No data available', 'jetpack-charts' );
99
71
 
100
72
  const hasInvalidData = data.some( series =>
101
73
  series.data.some(
102
74
  point =>
103
- isNaN( point.value as number ) ||
104
- point.value === null ||
105
- point.value === undefined ||
75
+ isInvalidReading( point.value, { allowMissing: true } ) ||
106
76
  ( ! point.label &&
107
77
  ( ! ( 'date' in point && point.date ) || isNaN( point.date.getTime() ) ) )
108
78
  )
109
79
  );
110
80
 
111
- if ( hasInvalidData ) return 'Invalid data';
81
+ if ( hasInvalidData ) return __( 'Invalid data', 'jetpack-charts' );
112
82
  return null;
113
83
  };
114
84
 
@@ -155,8 +125,16 @@ const BarChartInternal: FC< BarChartProps > = ( {
155
125
  onPointerUp,
156
126
  onDatumActivate,
157
127
  } ) => {
128
+ if ( ! withTooltips && ( withBandHighlight || onBandHighlightChange ) ) {
129
+ warnOnce(
130
+ 'bar-chart-band-highlight-without-tooltips',
131
+ 'BarChart: withBandHighlight and onBandHighlightChange require withTooltips.'
132
+ );
133
+ }
134
+
158
135
  const legendInteractive = legend.interactive ?? false;
159
136
  const legendCollapseGroups = legend.collapseGroups ?? false;
137
+ const legendComparisonItem = legend.comparisonItem ?? false;
160
138
  const horizontal = orientation === 'horizontal';
161
139
  const chartId = useChartId( providedChartId );
162
140
  const hiddenSeries = useDefaultHiddenSeries( chartId, defaultHiddenSeries );
@@ -177,8 +155,8 @@ const BarChartInternal: FC< BarChartProps > = ( {
177
155
 
178
156
  // Create legend items using the reusable hook
179
157
  const legendOptions = useMemo(
180
- () => ( { collapseGroups: legendCollapseGroups } ),
181
- [ legendCollapseGroups ]
158
+ () => ( { collapseGroups: legendCollapseGroups, comparisonItem: legendComparisonItem } ),
159
+ [ legendCollapseGroups, legendComparisonItem ]
182
160
  );
183
161
  const legendItems = useChartLegendItems( dataSorted, legendOptions );
184
162
 
@@ -192,12 +170,24 @@ const BarChartInternal: FC< BarChartProps > = ( {
192
170
  [ isSeriesVisible ]
193
171
  );
194
172
 
173
+ const hasWholeNumberValues = useMemo(
174
+ () => hasOnlyWholeNumbers( dataSorted.filter( isSeriesRendered ) ),
175
+ [ dataSorted, isSeriesRendered ]
176
+ );
177
+
195
178
  const chartOptions = useBarChartOptions(
196
179
  dataWithVisibleZeros,
197
180
  horizontal,
198
181
  options,
199
182
  isSeriesRendered
200
183
  );
184
+ const valueAxis = horizontal ? chartOptions.axis.x : chartOptions.axis.y;
185
+ const callerValueDomain = horizontal ? options.xScale?.domain : options.yScale?.domain;
186
+ const wholeNumberTicksProps = {
187
+ axis: horizontal ? ( 'x' as const ) : ( 'y' as const ),
188
+ numTicks: valueAxis.numTicks,
189
+ enabled: hasWholeNumberValues && ! valueAxis.tickValues && ! callerValueDomain,
190
+ };
201
191
  const defaultMargin = useChartMargin( height, chartOptions, dataSorted, theme, horizontal );
202
192
  const chartRef = useRef< HTMLDivElement >( null );
203
193
 
@@ -404,14 +394,14 @@ const BarChartInternal: FC< BarChartProps > = ( {
404
394
 
405
395
  // With a paired comparison value, show the category as the header and one row
406
396
  // per period (primary + comparison).
407
- if ( comparisonEntry && comparisonDatum && comparisonDatum.value != null ) {
397
+ if ( comparisonEntry && comparisonDatum ) {
408
398
  return (
409
399
  <div className={ styles[ 'bar-chart__tooltip' ] }>
410
400
  <div className={ styles[ 'bar-chart__tooltip-header' ] }>{ categoryLabel }</div>
411
- { renderTooltipRow( primaryKey, formatNumber( nearestDatum.value as number ) ) }
401
+ { renderTooltipRow( primaryKey, formatReading( nearestDatum.value ) ) }
412
402
  { renderTooltipRow(
413
403
  comparisonEntry.series.label,
414
- formatNumber( comparisonDatum.value as number )
404
+ formatReading( comparisonDatum.value )
415
405
  ) }
416
406
  </div>
417
407
  );
@@ -420,7 +410,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
420
410
  return (
421
411
  <div className={ styles[ 'bar-chart__tooltip' ] }>
422
412
  <div className={ styles[ 'bar-chart__tooltip-header' ] }>{ primaryKey }</div>
423
- { renderTooltipRow( categoryLabel, formatNumber( nearestDatum.value as number ) ) }
413
+ { renderTooltipRow( categoryLabel, formatReading( nearestDatum.value ) ) }
424
414
  </div>
425
415
  );
426
416
  },
@@ -482,34 +472,33 @@ const BarChartInternal: FC< BarChartProps > = ( {
482
472
  const createKeyboardHighlightStyle = useCallback( () => {
483
473
  if ( selectedIndex === undefined ) return '';
484
474
 
485
- // Use only primary entries — comparison series have no .visx-bar elements so
475
+ // Use only primary entries: comparison series have no .visx-bar elements, so
486
476
  // their indices must not appear in the nth-child selector.
487
477
  // Pattern: [series1[0], series2[0], series3[0], series1[1], series2[1], series3[1], ...]
488
478
  const primaryCount = primaryEntries.length;
489
- const maxDataPoints = Math.max( ...primaryEntries.map( e => e.series.data.length ) );
490
- const dataPointIndex = Math.floor( selectedIndex / primaryCount );
491
- const seriesIndex = selectedIndex % primaryCount;
492
-
493
- // Only highlight if we're within valid bounds
494
- if ( dataPointIndex >= maxDataPoints || seriesIndex >= primaryCount ) {
479
+ if ( ! primaryCount ) {
495
480
  return '';
496
481
  }
497
-
482
+ const dataPointIndex = Math.floor( selectedIndex / primaryCount );
483
+ const seriesIndex = selectedIndex % primaryCount;
498
484
  const seriesData = primaryEntries[ seriesIndex ]?.series;
499
- if ( ! seriesData || dataPointIndex >= seriesData.data.length ) {
485
+ const datum = seriesData?.data[ dataPointIndex ];
486
+
487
+ if ( ! seriesData || ! datum || ! isBarRendered( datum ) ) {
500
488
  return '';
501
489
  }
502
490
 
503
- // Based on the DOM structure analysis:
504
- // - All bars are in a single .visx-bar-group
505
- // - Bars are ordered as: [series1[0], series1[1], series2[0], series2[1], ...]
506
- // - So we need to calculate the actual bar index in the DOM
507
- const actualBarIndex = seriesIndex * maxDataPoints + dataPointIndex;
491
+ // The single .visx-bar-group holds series after series, and only the points visx drew a bar for.
492
+ const domBarIndex =
493
+ primaryEntries
494
+ .slice( 0, seriesIndex )
495
+ .reduce( ( count, { series } ) => count + countRenderedBars( series.data ), 0 ) +
496
+ countRenderedBars( seriesData.data.slice( 0, dataPointIndex ) );
508
497
 
509
498
  // Use a CSS class selector instead of ID since useId() generates invalid CSS ID characters
510
499
  const generatedStyles = `
511
500
  .bar-chart[data-chart-id="bar-chart-${ chartId }"] .visx-bar-group .visx-bar:nth-child(${
512
- actualBarIndex + 1
501
+ domBarIndex + 1
513
502
  }) {
514
503
  stroke: #005fcc;
515
504
  stroke-width: 2px;
@@ -633,21 +622,33 @@ const BarChartInternal: FC< BarChartProps > = ( {
633
622
  ) }
634
623
 
635
624
  { ! allSeriesHidden && (
636
- <>
637
- { /* Visx forwards tickValues to its grid primitives but omits it from GridProps. */ }
638
- <Grid
639
- columns={ gridVisibility.includes( 'y' ) }
640
- rows={ false }
641
- numTicks={ 4 }
642
- { ...{ tickValues: chartOptions.axis.x.tickValues } }
643
- />
644
- <Grid
645
- columns={ false }
646
- rows={ gridVisibility.includes( 'x' ) }
647
- numTicks={ 4 }
648
- { ...{ tickValues: chartOptions.axis.y.tickValues } }
649
- />
650
- </>
625
+ <WholeNumberTicks { ...wholeNumberTicksProps }>
626
+ { valueTicks => (
627
+ <>
628
+ { /* Visx forwards tickValues to its grid primitives but omits it from GridProps. */ }
629
+ <Grid
630
+ columns={ gridVisibility.includes( 'y' ) }
631
+ rows={ false }
632
+ numTicks={ chartOptions.axis.x.numTicks }
633
+ { ...{
634
+ tickValues:
635
+ ( horizontal ? valueTicks : undefined ) ??
636
+ chartOptions.axis.x.tickValues,
637
+ } }
638
+ />
639
+ <Grid
640
+ columns={ false }
641
+ rows={ gridVisibility.includes( 'x' ) }
642
+ numTicks={ chartOptions.axis.y.numTicks }
643
+ { ...{
644
+ tickValues:
645
+ ( horizontal ? undefined : valueTicks ) ??
646
+ chartOptions.axis.y.tickValues,
647
+ } }
648
+ />
649
+ </>
650
+ ) }
651
+ </WholeNumberTicks>
651
652
  ) }
652
653
 
653
654
  { withPatterns && (
@@ -671,7 +672,11 @@ const BarChartInternal: FC< BarChartProps > = ( {
671
672
  </>
672
673
  ) }
673
674
 
674
- { highlightedBarStyle && <style>{ highlightedBarStyle }</style> }
675
+ { highlightedBarStyle && (
676
+ <style data-testid="bar-chart-keyboard-highlight">
677
+ { highlightedBarStyle }
678
+ </style>
679
+ ) }
675
680
 
676
681
  { allSeriesHidden ? (
677
682
  <SvgEmptyState
@@ -726,10 +731,22 @@ const BarChartInternal: FC< BarChartProps > = ( {
726
731
  visx collapses the domain and the axes render squished at the top. Drop them
727
732
  while the empty state stands in. */ }
728
733
  { ! allSeriesHidden && (
729
- <>
730
- <Axis { ...chartOptions.axis.x } />
731
- <Axis { ...chartOptions.axis.y } />
732
- </>
734
+ <WholeNumberTicks { ...wholeNumberTicksProps }>
735
+ { valueTicks => (
736
+ <>
737
+ <Axis
738
+ { ...chartOptions.axis.x }
739
+ { ...( horizontal && valueTicks ? { tickValues: valueTicks } : {} ) }
740
+ />
741
+ <Axis
742
+ { ...chartOptions.axis.y }
743
+ { ...( ! horizontal && valueTicks
744
+ ? { tickValues: valueTicks }
745
+ : {} ) }
746
+ />
747
+ </>
748
+ ) }
749
+ </WholeNumberTicks>
733
750
  ) }
734
751
 
735
752
  { withTooltips && (
@@ -1,2 +1,2 @@
1
1
  export { default as BarChart, BarChartUnresponsive } from './bar-chart';
2
- export type { BarChartProps, BandHighlightSelection } from './bar-chart';
2
+ export type { BarChartProps, BandHighlightSelection } from './types';
@@ -4,7 +4,7 @@ import { useDeepMemo } from '../../../hooks';
4
4
  import { CATALOG_POINTERS } from '../../../providers/chart-context/private/catalog-pointers';
5
5
  import type { BandScale } from './band-scale';
6
6
  import type { DataPointDate } from '../../../types';
7
- import type { BandHighlightSelection } from '../bar-chart';
7
+ import type { BandHighlightSelection } from '../types';
8
8
 
9
9
  type Props = {
10
10
  visible: boolean;
@@ -41,6 +41,8 @@ export function BandHighlight( { visible, horizontal, onChange }: Props ) {
41
41
  }
42
42
  return {
43
43
  datum: nearest.datum as DataPointDate,
44
+ key: nearest.key,
45
+ index: nearest.index,
44
46
  x: horizontal ? ( margin?.left ?? 0 ) : position,
45
47
  y: horizontal ? position : ( margin?.top ?? 0 ),
46
48
  width: horizontal ? innerWidth : scale.bandwidth(),
@@ -2,7 +2,7 @@ import { DataContext, TooltipContext, useEventEmitter } from '@visx/xychart';
2
2
  import { useCallback, useContext, useMemo } from 'react';
3
3
  import { createGroupScale } from './band-scale';
4
4
  import type { BandScale } from './band-scale';
5
- import type { BarChartProps } from '../bar-chart';
5
+ import type { BarChartProps } from '../types';
6
6
 
7
7
  type PointerHandler = NonNullable< Parameters< typeof useEventEmitter >[ 1 ] >;
8
8
  type PointerSelection = Parameters< NonNullable< BarChartProps[ 'onPointerUp' ] > >[ 0 ];
@@ -9,5 +9,6 @@ export {
9
9
  COMPARISON_TICK_GAP_FACTOR,
10
10
  } from './comparison-constants';
11
11
  export { BASE_BAND_PADDING_INNER } from './use-bar-chart-options';
12
+ export { countRenderedBars, isBarRendered } from './value-domain';
12
13
  export { BandHighlight } from './band-highlight';
13
14
  export { BandTooltip } from './band-tooltip';
@@ -7,7 +7,7 @@ import { GlobalChartsProvider } from '../../../../providers';
7
7
  import { CATALOG_POINTERS } from '../../../../providers/chart-context/private/catalog-pointers';
8
8
  import { BarChartUnresponsive } from '../../bar-chart';
9
9
  import { BandHighlight } from '../band-highlight';
10
- import type { BandHighlightSelection } from '../../bar-chart';
10
+ import type { BandHighlightSelection } from '../../types';
11
11
 
12
12
  const xScale = scaleBand( { domain: [ 'Jan', 'Feb' ], range: [ 10, 210 ] } );
13
13
  const dataContext = {
@@ -25,7 +25,7 @@ const Fixture = ( { label = 'Jan', onChange } ) => {
25
25
  value={
26
26
  {
27
27
  tooltipOpen: true,
28
- tooltipData: { nearestDatum: { key: 'scores', datum: { label, value: 50 } } },
28
+ tooltipData: { nearestDatum: { key: 'scores', index: 0, datum: { label, value: 50 } } },
29
29
  } as never
30
30
  }
31
31
  >
@@ -38,6 +38,20 @@ const Fixture = ( { label = 'Jan', onChange } ) => {
38
38
  };
39
39
 
40
40
  describe( 'BandHighlight', () => {
41
+ test( 'reports the initial selection and clears it on unmount', () => {
42
+ const onChange = jest.fn();
43
+ const { unmount } = render( <Fixture onChange={ onChange } /> );
44
+ expect( onChange ).toHaveBeenLastCalledWith(
45
+ expect.objectContaining( {
46
+ key: 'scores',
47
+ index: 0,
48
+ datum: { label: 'Jan', value: 50 },
49
+ } )
50
+ );
51
+ unmount();
52
+ expect( onChange ).toHaveBeenLastCalledWith( null );
53
+ } );
54
+
41
55
  test( 'uses the catalog surface fill', () => {
42
56
  render( <Fixture onChange={ undefined } /> );
43
57
  expect( screen.getByTestId( 'bar-chart-band-highlight' ) ).toHaveAttribute(
@@ -79,6 +93,7 @@ describe( 'BandHighlight', () => {
79
93
  test.each( [ { withTooltips: false }, { data: [] } ] )(
80
94
  'clears the selected band when the highlight unmounts with %j',
81
95
  async props => {
96
+ const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
82
97
  const user = userEvent.setup();
83
98
  const onChange = jest.fn();
84
99
  const chartProps = {
@@ -103,6 +118,17 @@ describe( 'BandHighlight', () => {
103
118
  <BarChartUnresponsive { ...chartProps } { ...props } />
104
119
  </GlobalChartsProvider>
105
120
  );
121
+ rerender(
122
+ <GlobalChartsProvider>
123
+ <BarChartUnresponsive
124
+ { ...chartProps }
125
+ { ...props }
126
+ onBandHighlightChange={ jest.fn() }
127
+ />
128
+ </GlobalChartsProvider>
129
+ );
130
+ expect( warn ).toHaveBeenCalledTimes( 'withTooltips' in props ? 1 : 0 );
131
+ warn.mockRestore();
106
132
  expect( onChange ).toHaveBeenLastCalledWith( null );
107
133
  }
108
134
  );