@automattic/charts 4.14.0 → 4.15.1

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 (49) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/TOKENS.md +1 -1
  3. package/dist/index.cjs +649 -882
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +58 -1
  6. package/dist/index.d.cts +41 -7
  7. package/dist/index.d.ts +41 -7
  8. package/dist/index.js +650 -883
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -8
  11. package/src/charts/area-chart/area-chart.tsx +29 -20
  12. package/src/charts/area-chart/types.ts +3 -1
  13. package/src/charts/bar-chart/bar-chart.module.scss +38 -0
  14. package/src/charts/bar-chart/bar-chart.tsx +159 -56
  15. package/src/charts/bar-chart/private/band-tooltip.tsx +40 -5
  16. package/src/charts/bar-chart/private/bar-layer.tsx +66 -13
  17. package/src/charts/bar-chart/private/category.ts +25 -0
  18. package/src/charts/bar-chart/private/comparison-bars-geometry.ts +13 -1
  19. package/src/charts/bar-chart/private/index.ts +3 -1
  20. package/src/charts/bar-chart/private/selected-bar-ring.tsx +17 -6
  21. package/src/charts/bar-chart/private/stack-layout.ts +99 -0
  22. package/src/charts/bar-chart/private/stacked-bar-series.tsx +145 -0
  23. package/src/charts/bar-chart/private/test/stack-layout.test.ts +81 -0
  24. package/src/charts/bar-chart/private/test/value-domain.test.ts +10 -0
  25. package/src/charts/bar-chart/private/use-bar-chart-options.ts +35 -11
  26. package/src/charts/bar-chart/private/{use-overlap-slots.ts → use-bar-slots.ts} +36 -38
  27. package/src/charts/bar-chart/private/use-bar-stops.ts +19 -19
  28. package/src/charts/bar-chart/private/value-domain.ts +10 -8
  29. package/src/charts/bar-chart/test/bar-chart-stacked.test.tsx +546 -0
  30. package/src/charts/bar-chart/test/bar-chart.test.tsx +75 -0
  31. package/src/charts/bar-chart/test/use-bar-legend-items.test.tsx +18 -0
  32. package/src/charts/bar-chart/types.ts +3 -1
  33. package/src/charts/bar-chart/use-bar-colors.ts +12 -0
  34. package/src/charts/bar-chart/use-bar-legend-items.ts +4 -2
  35. package/src/charts/bar-list-chart/bar-list-chart.tsx +2 -0
  36. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +43 -1
  37. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +25 -20
  38. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +15 -20
  39. package/src/charts/line-chart/line-chart.tsx +55 -26
  40. package/src/charts/line-chart/test/line-chart.test.tsx +86 -0
  41. package/src/charts/line-chart/types.ts +3 -1
  42. package/src/charts/private/test/compact-when-short.test.tsx +170 -0
  43. package/src/charts/private/test/tick-decimals.test.tsx +95 -0
  44. package/src/charts/private/tick-decimals.tsx +132 -0
  45. package/src/charts/private/use-compact-when-short.ts +18 -0
  46. package/src/providers/chart-context/themes.ts +4 -0
  47. package/src/types.ts +35 -2
  48. package/src/charts/private/test/whole-number-ticks.test.tsx +0 -57
  49. package/src/charts/private/whole-number-ticks.tsx +0 -79
@@ -1,5 +1,6 @@
1
1
  import { useMemo } from 'react';
2
2
  import { useChartLegendItems } from '../../components/legend/hooks/use-chart-legend-items';
3
+ import { getDrawnSeries } from './private/stack-layout';
3
4
  import { useBarColors } from './use-bar-colors';
4
5
  import type { ChartLegendOptions } from '../../components/legend/hooks/use-chart-legend-items';
5
6
  import type { BaseLegendItem } from '../../components/legend/types';
@@ -58,17 +59,18 @@ function forBarLayout(
58
59
  /**
59
60
  * Builds legend items that match `BarChart`'s bars in a layout, for a custom legend or a chart's own.
60
61
  *
61
- * @param data - The series the chart draws.
62
+ * @param allData - The chart's series; those the layout does not draw get no item.
62
63
  * @param options - The legend options, and the layout the bars are drawn in.
63
64
  * @param legendShape - The shape of the legend swatches.
64
65
  * @return The legend items.
65
66
  */
66
67
  export function useBarLegendItems(
67
- data: SeriesData[],
68
+ allData: SeriesData[],
68
69
  options: BarLegendOptions = {},
69
70
  legendShape?: LegendShape< SeriesData[], number >
70
71
  ): BaseLegendItem[] {
71
72
  const { layout } = options;
73
+ const data = useMemo( () => getDrawnSeries( allData, layout ), [ allData, layout ] );
72
74
  const getBarColors = useBarColors();
73
75
  const seriesColors = useMemo(
74
76
  () => ( layout ? getBarColors( data, { layout } ) : undefined ),
@@ -20,6 +20,8 @@ export interface BarListChartProps extends Exclude<
20
20
  BarChartProps,
21
21
  'orientation' | 'size' | 'gridVisibility'
22
22
  > {
23
+ /** Bar lists are always horizontal, and horizontal bars ignore `compactWhenShort`. */
24
+ compactWhenShort?: never;
23
25
  options?: {
24
26
  /**
25
27
  * Scale for the y axis. Exclude the type property.
@@ -1,3 +1,6 @@
1
+ // Room for the chevron an interactive row reveals on hover or focus.
2
+ $hover-reserve: var(--wpds-dimension-gap-2xl);
3
+
1
4
  .leaderboardChart {
2
5
  transition:
3
6
  opacity var(--wpds-motion-duration-lg)
@@ -28,6 +31,17 @@
28
31
  }
29
32
  }
30
33
 
34
+ .grid {
35
+ display: grid;
36
+ grid-template-columns: minmax(0, 1fr) auto;
37
+ row-gap: var(--a8c-charts-dimension-leaderboard-row-gap);
38
+ column-gap: var(--a8c-charts-dimension-leaderboard-column-gap);
39
+
40
+ &--comparison {
41
+ grid-template-columns: minmax(0, 1fr) auto auto;
42
+ }
43
+ }
44
+
31
45
  .barWithLabelContainer {
32
46
  display: grid;
33
47
  align-items: center;
@@ -233,6 +247,33 @@ $focus-ring-width: var(--wpds-border-width-focus);
233
247
  var(--wpds-motion-easing-subtle);
234
248
  }
235
249
 
250
+ .barWithLabelContainer > :not(.bar, .barTrack) {
251
+ max-inline-size: 100%;
252
+ transition:
253
+ max-inline-size var(--wpds-motion-duration-sm)
254
+ var(--wpds-motion-easing-subtle);
255
+ }
256
+
257
+ // Single-line, so the hover cap below moves the ellipsis instead of
258
+ // rewrapping the label and growing the row.
259
+ .label {
260
+ overflow: hidden;
261
+ text-overflow: ellipsis;
262
+ white-space: nowrap;
263
+ }
264
+
265
+ // The label stays clear of the slid value on any hover or focus, nested
266
+ // controls included: lifting it while a trailing action is hovered would
267
+ // move that action out from under the pointer.
268
+ &:hover,
269
+ &:focus-visible,
270
+ &:has(:focus-visible) {
271
+
272
+ .barWithLabelContainer > :not(.bar, .barTrack) {
273
+ max-inline-size: calc(100% - #{$hover-reserve});
274
+ }
275
+ }
276
+
236
277
  .valueContainer {
237
278
  transition:
238
279
  transform var(--wpds-motion-duration-sm)
@@ -246,7 +287,7 @@ $focus-ring-width: var(--wpds-border-width-focus);
246
287
 
247
288
  // Fixed reserve for the chevron — the value slides by it in full, and the
248
289
  // bar width inset (see leaderboard-chart.tsx) scales it by the bar's share.
249
- --a8c-charts-dimension-leaderboard-bar-hover-inset: var(--wpds-dimension-gap-2xl);
290
+ --a8c-charts-dimension-leaderboard-bar-hover-inset: #{$hover-reserve};
250
291
 
251
292
  .bar {
252
293
  opacity: 0.5;
@@ -343,6 +384,7 @@ $focus-ring-width: var(--wpds-border-width-focus);
343
384
  .interactiveRow {
344
385
 
345
386
  .bar,
387
+ .barWithLabelContainer > :not(.bar, .barTrack),
346
388
  .valueContainer,
347
389
  .chevron {
348
390
  transition: none;
@@ -1,8 +1,6 @@
1
- /* eslint-disable @wordpress/no-unsafe-wp-apis */
2
- import { __experimentalGrid as Grid, VisuallyHidden } from '@wordpress/components';
3
1
  import { __ } from '@wordpress/i18n';
4
2
  import { Icon, chevronRight } from '@wordpress/icons';
5
- import { Stack, Text } from '@wordpress/ui';
3
+ import { Stack, Text, VisuallyHidden } from '@wordpress/ui';
6
4
  import clsx from 'clsx';
7
5
  import { useContext, useMemo, type FC, type MouseEvent } from 'react';
8
6
  import { Legend } from '../../components/legend';
@@ -30,12 +28,6 @@ import {
30
28
  import type { LeaderboardChartProps } from './types';
31
29
  import type { LeaderboardEntry } from '../../types';
32
30
 
33
- // Handed to `Grid` as a chain rather than set in this component's stylesheet: `Grid` writes
34
- // its own gaps through an Emotion class, which a module class of equal specificity would not
35
- // reliably outrank. The chain lands on the element and resolves there.
36
- const ROW_GAP = 'var(--a8c-charts-dimension-leaderboard-row-gap, 12px)';
37
- const COLUMN_GAP = 'var(--a8c-charts-dimension-leaderboard-column-gap, 4px)';
38
-
39
31
  /** Indexed by `Math.sign( delta ) + 1`, so [negative, neutral, positive]. */
40
32
  const DELTA_TREND_CLASS = [ 'deltaValue--down', 'deltaValue--neutral', 'deltaValue--up' ] as const;
41
33
 
@@ -107,8 +99,22 @@ function forwardCellClickToRowButton( event: MouseEvent< HTMLDivElement > ) {
107
99
  event.currentTarget.querySelector< HTMLButtonElement >( ':scope > button' )?.click();
108
100
  }
109
101
 
110
- const BarLabel = ( { label }: { label: LeaderboardEntry[ 'label' ] } ) => (
111
- <>{ typeof label === 'string' ? <Text className={ styles.label }>{ label }</Text> : label }</>
102
+ const BarLabel = ( {
103
+ label,
104
+ isInteractiveRow,
105
+ }: {
106
+ label: LeaderboardEntry[ 'label' ];
107
+ isInteractiveRow: boolean;
108
+ } ) => (
109
+ <>
110
+ { typeof label === 'string' ? (
111
+ <Text className={ styles.label } title={ isInteractiveRow ? label : undefined }>
112
+ { label }
113
+ </Text>
114
+ ) : (
115
+ label
116
+ ) }
117
+ </>
112
118
  );
113
119
 
114
120
  /** The largest negative and positive share magnitudes, which size each side of zero. */
@@ -213,7 +219,7 @@ const BarWithLabel = ( {
213
219
  } ) }
214
220
  data-testid={ divergingScale ? 'leaderboard-chart-diverging-row' : undefined }
215
221
  >
216
- <BarLabel label={ entry.label } />
222
+ <BarLabel label={ entry.label } isInteractiveRow={ Boolean( entry.onClick ) } />
217
223
  { bars }
218
224
  </div>
219
225
  );
@@ -388,8 +394,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
388
394
  // Comparing adds a delta track so the values and the deltas each line up in a column
389
395
  // of their own; with either series hidden the grid keeps its two tracks.
390
396
  const showComparisonColumn = withComparison && isComparisonVisible;
391
- const templateColumns =
392
- showComparisonColumn && isPrimaryVisible ? 'minmax(0, 1fr) auto auto' : 'minmax(0, 1fr) auto';
397
+ const hasDeltaColumn = showComparisonColumn && isPrimaryVisible;
393
398
 
394
399
  // Handle empty or undefined data
395
400
  if ( ! data || data.length === 0 ) {
@@ -480,13 +485,13 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
480
485
  { getAllHiddenMessage( legendInteractive, 'series' ) }
481
486
  </div>
482
487
  ) : (
483
- <Grid
488
+ <div
484
489
  role="list"
485
490
  aria-label={ ariaLabel ?? __( 'Leaderboard chart', 'jetpack-charts' ) }
486
- templateColumns={ templateColumns }
487
- rowGap={ ROW_GAP }
488
- columnGap={ COLUMN_GAP }
489
491
  data-leaderboard-grid
492
+ className={ clsx( styles.grid, {
493
+ [ styles[ 'grid--comparison' ] ]: hasDeltaColumn,
494
+ } ) }
490
495
  >
491
496
  { resolvedData.map( ( entry, rowIndex ) => {
492
497
  // visibility, not clipping: hidden rows keep their geometry (so a
@@ -544,7 +549,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
544
549
  ) }
545
550
  >
546
551
  <span aria-hidden="true">—</span>
547
- <VisuallyHidden as="span">
552
+ <VisuallyHidden render={ <span /> }>
548
553
  { hasPreviousPeriodValue
549
554
  ? __( 'Percentage change unavailable', 'jetpack-charts' )
550
555
  : __( 'No comparison data', 'jetpack-charts' ) }
@@ -616,7 +621,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
616
621
  </div>
617
622
  );
618
623
  } ) }
619
- </Grid>
624
+ </div>
620
625
  ) }
621
626
  </div>
622
627
  </ChartLayout>
@@ -802,18 +802,25 @@ describe( 'LeaderboardChart', () => {
802
802
  );
803
803
  expect( screen.getAllByRole( 'button' ) ).toHaveLength( 1 );
804
804
  } );
805
+
806
+ it( 'titles only an interactive row’s string label with its full text', () => {
807
+ const [ interactive, inert ] = mockData;
808
+ render( <LeaderboardChart data={ [ { ...interactive, onClick: jest.fn() }, inert ] } /> );
809
+ expect( screen.getByText( String( interactive.label ) ) ).toHaveAttribute(
810
+ 'title',
811
+ interactive.label
812
+ );
813
+ expect( screen.getByText( String( inert.label ) ) ).not.toHaveAttribute( 'title' );
814
+ } );
805
815
  } );
806
816
 
807
- describe( 'grid gaps', () => {
808
- // eslint-disable-next-line testing-library/no-node-access -- The grid is rendered by `Grid` from @wordpress/components, which takes no test id.
809
- const grid = () => document.querySelector< HTMLElement >( '[data-leaderboard-grid]' );
810
- const templateColumns = () =>
811
- grid().style.getPropertyValue( '--wp-components-grid-template-columns' );
817
+ describe( 'grid columns', () => {
818
+ const grid = () => screen.getByRole( 'list', { name: 'Leaderboard chart' } );
812
819
 
813
820
  it( 'lays the rows out without a delta track when not comparing', () => {
814
821
  render( <LeaderboardChart data={ mockData } withComparison={ false } /> );
815
822
 
816
- expect( templateColumns() ).toBe( 'minmax(0, 1fr) auto' );
823
+ expect( grid() ).not.toHaveClass( 'grid--comparison' );
817
824
  } );
818
825
 
819
826
  it.each( [ [ 'Previous period' ], [ 'Current period' ] ] )(
@@ -829,24 +836,12 @@ describe( 'LeaderboardChart', () => {
829
836
  />
830
837
  );
831
838
 
832
- expect( templateColumns() ).toBe( 'minmax(0, 1fr) auto auto' );
839
+ expect( grid() ).toHaveClass( 'grid--comparison' );
833
840
 
834
841
  await user.click( screen.getByRole( 'button', { name: new RegExp( legendItem ) } ) );
835
842
 
836
- expect( templateColumns() ).toBe( 'minmax(0, 1fr) auto' );
843
+ expect( grid() ).not.toHaveClass( 'grid--comparison' );
837
844
  }
838
845
  );
839
-
840
- it( 'hands the catalog role to the grid', () => {
841
- render( <LeaderboardChart data={ mockData } /> );
842
-
843
- // Grid writes its gaps into custom properties and reads them from a stylesheet.
844
- expect( grid().style.getPropertyValue( '--wp-components-grid-row-gap' ) ).toBe(
845
- 'var(--a8c-charts-dimension-leaderboard-row-gap, 12px)'
846
- );
847
- expect( grid().style.getPropertyValue( '--wp-components-grid-column-gap' ) ).toBe(
848
- 'var(--a8c-charts-dimension-leaderboard-column-gap, 4px)'
849
- );
850
- } );
851
846
  } );
852
847
  } );
@@ -41,9 +41,15 @@ import { ChartLayout } from '../private/chart-layout';
41
41
  import { DefaultGlyph } from '../private/default-glyph';
42
42
  import { formatReading, isInvalidReading, isReading } from '../private/readings';
43
43
  import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
44
+ import {
45
+ getTickDecimals,
46
+ getTickDecimalsDomain,
47
+ hasOnlyWholeNumbers,
48
+ TickDecimals,
49
+ } from '../private/tick-decimals';
44
50
  import { getCurveType } from '../private/time-axis';
45
51
  import { buildTimeAxisOptions } from '../private/time-axis-options';
46
- import { hasOnlyWholeNumbers, WholeNumberTicks } from '../private/whole-number-ticks';
52
+ import { useCompactWhenShort } from '../private/use-compact-when-short';
47
53
  import { withResponsive } from '../private/with-responsive';
48
54
  import { useXZoom, ZoomResetButton, ZoomSelectionRect, ZoomClip } from '../private/x-zoom';
49
55
  import plotStyles from '../private/xy-plot/xy-plot.module.scss';
@@ -267,6 +273,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
267
273
  children,
268
274
  gridVisibility,
269
275
  gap = 'md',
276
+ compactWhenShort = false,
270
277
  },
271
278
  ref
272
279
  ) => {
@@ -301,6 +308,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
301
308
 
302
309
  // Process children for composition API (Legend, etc.)
303
310
  const { legendChildren, nonLegendChildren } = useChartChildren( children, 'LineChart' );
311
+ const { isCompact, compactMargin } = useCompactWhenShort( compactWhenShort, height );
304
312
  const [ measuredChartHeight, setMeasuredChartHeight ] = useState< number | undefined >();
305
313
 
306
314
  // Callback for ChartLayout to notify us when the measured content height changes.
@@ -438,31 +446,47 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
438
446
  () => hasOnlyWholeNumbers( visibleSeries ),
439
447
  [ visibleSeries ]
440
448
  );
449
+ const isLogScale = options?.yScale?.type === 'log';
450
+ // A log axis has no decimal steps to hold ticks to.
451
+ const callerTickDecimals = isLogScale ? undefined : options?.axis?.y?.tickDecimals;
452
+ const yTickDecimals = getTickDecimals( hasWholeNumberValues, callerTickDecimals );
441
453
 
442
454
  const chartOptions = useMemo( () => {
443
- const fallbackYDomain = getFallbackYDomain(
444
- visibleReadingExtent,
445
- options?.yScale?.type === 'log'
446
- );
455
+ const fallbackYDomain = getFallbackYDomain( visibleReadingExtent, isLogScale );
456
+ const { tickDecimals, ...yAxisOptions } = options?.axis?.y ?? {};
457
+ const tickDecimalsDomain =
458
+ isLogScale ||
459
+ tickDecimals === undefined ||
460
+ options?.yScale?.domain ||
461
+ yAxisOptions.tickValues
462
+ ? undefined
463
+ : getTickDecimalsDomain(
464
+ yDomain ?? fallbackYDomain ?? visibleReadingExtent,
465
+ yTickDecimals
466
+ );
447
467
 
448
468
  return {
449
469
  axis: {
450
- x: buildTimeAxisOptions( {
451
- dataSorted,
452
- width,
453
- axisOptions: options?.axis?.x,
454
- scaleDomain: options?.xScale?.domain,
455
- zoomDomain: zoom.domain,
456
- formatting,
457
- // A hidden line is unmounted, so visx scales to the rest.
458
- isSeriesRendered: series => isSeriesVisible( series.label ),
459
- } ),
470
+ x: {
471
+ ...buildTimeAxisOptions( {
472
+ dataSorted,
473
+ width,
474
+ axisOptions: options?.axis?.x,
475
+ scaleDomain: options?.xScale?.domain,
476
+ zoomDomain: zoom.domain,
477
+ formatting,
478
+ // A hidden line is unmounted, so visx scales to the rest.
479
+ isSeriesRendered: series => isSeriesVisible( series.label ),
480
+ } ),
481
+ ...( isCompact && { display: false } ),
482
+ },
460
483
  y: {
461
484
  orientation: 'left' as const,
462
485
  numTicks: 4,
463
486
  tickFormat: formatNumberCompact as TickFormatter< number >,
464
487
  display: true,
465
- ...options?.axis?.y,
488
+ ...yAxisOptions,
489
+ ...( isCompact && { display: false } ),
466
490
  },
467
491
  },
468
492
  xScale: {
@@ -476,6 +500,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
476
500
  zero: false,
477
501
  ...( fallbackYDomain ? { domain: fallbackYDomain } : {} ),
478
502
  ...( yDomain ? { domain: yDomain } : {} ),
503
+ ...( tickDecimalsDomain ? { domain: tickDecimalsDomain } : {} ),
479
504
  ...options?.yScale,
480
505
  },
481
506
  };
@@ -486,8 +511,11 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
486
511
  zoom.domain,
487
512
  yDomain,
488
513
  visibleReadingExtent,
514
+ isLogScale,
515
+ yTickDecimals,
489
516
  formatting,
490
517
  isSeriesVisible,
518
+ isCompact,
491
519
  ] );
492
520
 
493
521
  // Classified from the rendered series, like the axis above: a hidden
@@ -588,7 +616,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
588
616
  return <div className={ clsx( 'line-chart', styles[ 'line-chart' ] ) }>{ error }</div>;
589
617
  }
590
618
 
591
- const legendElement = showLegend && (
619
+ const legendElement = showLegend && ! isCompact && (
592
620
  <Legend
593
621
  orientation={ legend.orientation ?? 'horizontal' }
594
622
  alignment={ legend.alignment ?? 'center' }
@@ -617,7 +645,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
617
645
  <ChartLayout
618
646
  legendPosition={ legendPosition }
619
647
  legendElement={ legendElement }
620
- legendChildren={ legendChildren }
648
+ legendChildren={ isCompact ? [] : legendChildren }
621
649
  gap={ gap }
622
650
  className={ clsx(
623
651
  'line-chart',
@@ -655,6 +683,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
655
683
  height={ chartHeight }
656
684
  margin={ {
657
685
  ...defaultMargin,
686
+ ...compactMargin,
658
687
  ...margin,
659
688
  } }
660
689
  // xScale and yScale could be set in Axis as well, but they are `scale` props there.
@@ -666,18 +695,18 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
666
695
  { /* With every series hidden there is no data to scale against, so the grid and
667
696
  axes are dropped while the empty state stands in — otherwise they render
668
697
  squished at the top. */ }
669
- <WholeNumberTicks
698
+ <TickDecimals
670
699
  axis="y"
671
700
  numTicks={ chartOptions.axis.y.numTicks }
672
- enabled={
673
- hasWholeNumberValues &&
674
- ! chartOptions.axis.y.tickValues &&
675
- ! options?.yScale?.domain
701
+ decimals={
702
+ ! chartOptions.axis.y.tickValues && ! options?.yScale?.domain
703
+ ? yTickDecimals
704
+ : undefined
676
705
  }
677
706
  >
678
707
  { tickValues => (
679
708
  <>
680
- { ! allSeriesHidden && gridVisibility !== 'none' && (
709
+ { ! allSeriesHidden && gridVisibility !== 'none' && ! isCompact && (
681
710
  <Grid
682
711
  columns={ false }
683
712
  numTicks={ chartOptions.axis.y.numTicks }
@@ -695,7 +724,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
695
724
  ) }
696
725
  </>
697
726
  ) }
698
- </WholeNumberTicks>
727
+ </TickDecimals>
699
728
 
700
729
  { allSeriesHidden ? (
701
730
  <SvgEmptyState
@@ -704,7 +733,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
704
733
  width={ width }
705
734
  height={ chartHeight }
706
735
  >
707
- { getAllHiddenMessage( legendInteractive, 'series' ) }
736
+ { getAllHiddenMessage( legendInteractive && ! isCompact, 'series' ) }
708
737
  </SvgEmptyState>
709
738
  ) : null }
710
739
 
@@ -313,6 +313,92 @@ describe( 'LineChart', () => {
313
313
  expect( ticks.sort() ).toEqual( [ '0', '1' ] );
314
314
  } );
315
315
 
316
+ test.each( [
317
+ [ 'small amounts', 0, [ 0.4, 1.2, 2.1, 2.6 ], [ '0', '1', '2' ] ],
318
+ [ 'amounts under one step', 0, [ 0.31, 0.55, 0.73 ], [ '0', '1' ] ],
319
+ [ 'cents', 2, [ 0.4, 1.2, 1.6 ], [ '0.00', '0.50', '1.00', '1.50' ] ],
320
+ ] )( 'holds ticks to tickDecimals for %s', ( _, tickDecimals, values, expected ) => {
321
+ renderWithTheme( {
322
+ data: [
323
+ {
324
+ label: 'Series A',
325
+ data: values.map( ( value, i ) => ( { date: new Date( 2024, i + 2, 1 ), value } ) ),
326
+ },
327
+ ],
328
+ options: {
329
+ yScale: { zero: true },
330
+ axis: {
331
+ y: { tickDecimals, tickFormat: ( value: number ) => value.toFixed( tickDecimals ) },
332
+ },
333
+ },
334
+ } );
335
+
336
+ const chart = screen.getByRole( 'application', { name: /line chart/i } );
337
+ const ticks = within( chart )
338
+ .getAllByText( /^-?[\d.,]+$/ )
339
+ .map( el => el.textContent );
340
+ expect( ticks.sort() ).toEqual( expected );
341
+ } );
342
+
343
+ test( "leaves a caller's tickValues axis unwidened by tickDecimals", () => {
344
+ const linePath = ( y: { tickValues: number[]; tickDecimals?: number } ) => {
345
+ const { unmount } = renderWithTheme( {
346
+ data: [
347
+ {
348
+ label: 'Series A',
349
+ data: [ 0.31, 0.55, 0.73 ].map( ( value, i ) => ( {
350
+ date: new Date( 2024, i + 2, 1 ),
351
+ value,
352
+ } ) ),
353
+ },
354
+ ],
355
+ options: { axis: { y } },
356
+ } );
357
+ const chart = screen.getByRole( 'application', { name: /line chart/i } );
358
+ // eslint-disable-next-line testing-library/no-node-access -- visx's stroke is a <path class="visx-line">.
359
+ const d = chart.querySelector( 'path.visx-line' )?.getAttribute( 'd' );
360
+ unmount();
361
+ return d;
362
+ };
363
+
364
+ expect( linePath( { tickValues: [ 0.4, 0.6 ], tickDecimals: 0 } ) ).toBe(
365
+ linePath( { tickValues: [ 0.4, 0.6 ] } )
366
+ );
367
+ } );
368
+
369
+ test( 'widens with the decimals it ticks at when a hidden series keeps its place in the domain', () => {
370
+ renderWithTheme( {
371
+ rescaleYOnVisibilityChange: false,
372
+ defaultHiddenSeries: [ 'Series B' ],
373
+ data: [
374
+ {
375
+ label: 'Series A',
376
+ data: [ 0, 0, 0 ].map( ( value, i ) => ( {
377
+ date: new Date( 2024, i + 2, 1 ),
378
+ value,
379
+ } ) ),
380
+ },
381
+ {
382
+ label: 'Series B',
383
+ data: [ 0.001, 0.003, 0.004 ].map( ( value, i ) => ( {
384
+ date: new Date( 2024, i + 2, 1 ),
385
+ value,
386
+ } ) ),
387
+ },
388
+ ],
389
+ options: {
390
+ yScale: { zero: true },
391
+ axis: { y: { tickDecimals: 2, tickFormat: ( value: number ) => value.toFixed( 2 ) } },
392
+ },
393
+ } );
394
+
395
+ const chart = screen.getByRole( 'application', { name: /line chart/i } );
396
+ const ticks = within( chart )
397
+ .getAllByText( /^-?[\d.,]+$/ )
398
+ .map( el => el.textContent );
399
+ expect( ticks.sort() ).toEqual( [ '0.00', '1.00' ] );
400
+ } );
401
+
316
402
  test( 'ignores a hidden fractional series when checking for whole numbers', () => {
317
403
  renderWithTheme( {
318
404
  defaultHiddenSeries: [ 'Series B' ],
@@ -5,6 +5,7 @@ import type {
5
5
  SeriesData,
6
6
  SeriesChartLegendConfig,
7
7
  SeriesVisibilityProps,
8
+ CompactWhenShortProps,
8
9
  AnnotationStyles,
9
10
  DataPoint,
10
11
  } from '../../types';
@@ -30,7 +31,8 @@ export type RenderLineGlyphProps< Datum extends object > = GlyphProps< Datum > &
30
31
  position?: 'start' | 'end';
31
32
  };
32
33
 
33
- export interface LineChartProps extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps {
34
+ export interface LineChartProps
35
+ extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps, CompactWhenShortProps {
34
36
  /**
35
37
  * Legend configuration. Supports `collapseGroups` on top of the shared options.
36
38
  */