@automattic/charts 1.10.3 → 1.12.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 (49) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/TOKENS.md +83 -0
  3. package/dist/index.cjs +894 -139
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +58 -62
  6. package/dist/index.d.cts +75 -13
  7. package/dist/index.d.ts +75 -13
  8. package/dist/index.js +894 -139
  9. package/dist/index.js.map +1 -1
  10. package/package.json +2 -1
  11. package/src/charts/area-chart/area-chart.tsx +28 -8
  12. package/src/charts/area-chart/test/area-chart.test.tsx +102 -0
  13. package/src/charts/area-chart/types.ts +20 -6
  14. package/src/charts/bar-chart/bar-chart.tsx +33 -9
  15. package/src/charts/bar-chart/test/bar-chart.test.tsx +117 -0
  16. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +7 -7
  17. package/src/charts/heatmap-chart/heatmap-chart.module.scss +18 -13
  18. package/src/charts/heatmap-chart/heatmap-chart.tsx +11 -7
  19. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +3 -3
  20. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +13 -7
  21. package/src/charts/leaderboard-chart/hooks/index.ts +1 -0
  22. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +115 -0
  23. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +39 -12
  24. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +67 -14
  25. package/src/charts/leaderboard-chart/test/leaderboard-chart-fit-rows.test.tsx +365 -0
  26. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +73 -2
  27. package/src/charts/leaderboard-chart/types.ts +12 -0
  28. package/src/charts/line-chart/line-chart.module.scss +2 -2
  29. package/src/charts/line-chart/line-chart.tsx +40 -5
  30. package/src/charts/line-chart/test/line-chart.test.tsx +190 -1
  31. package/src/charts/line-chart/types.ts +14 -0
  32. package/src/charts/private/grid-control/grid-control.module.scss +1 -1
  33. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  34. package/src/charts/private/x-zoom/index.ts +1 -0
  35. package/src/charts/private/{test → x-zoom/test}/x-zoom.test.tsx +46 -3
  36. package/src/charts/private/x-zoom/x-zoom.module.scss +43 -0
  37. package/src/charts/private/{x-zoom.tsx → x-zoom/x-zoom.tsx} +52 -24
  38. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +152 -0
  39. package/src/components/legend/hooks/use-chart-legend-items.ts +118 -16
  40. package/src/components/legend/private/base-legend.tsx +21 -9
  41. package/src/components/legend/test/legend-config-types.test.tsx +32 -0
  42. package/src/components/legend/test/legend.test.tsx +121 -1
  43. package/src/components/legend/types.ts +6 -0
  44. package/src/components/tooltip/base-tooltip.module.scss +3 -2
  45. package/src/components/trend-indicator/trend-indicator.module.scss +6 -5
  46. package/src/index.ts +1 -0
  47. package/src/providers/chart-context/themes.ts +33 -19
  48. package/src/types.ts +18 -2
  49. package/src/charts/private/x-zoom.module.scss +0 -48
@@ -285,7 +285,7 @@ describe( 'HeatmapChart', () => {
285
285
  renderChart();
286
286
  const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
287
287
  // Non-compact sets no inline gap — the SCSS falls back to the WPDS token.
288
- expect( grid.style.getPropertyValue( '--heatmap-cell-gap' ) ).toBe( '' );
288
+ expect( grid.style.getPropertyValue( '--a8c-charts-dimension-heatmap-cell-gap' ) ).toBe( '' );
289
289
  } );
290
290
 
291
291
  test( 'applies the compact gap inline from the theme compactCellGap', () => {
@@ -295,7 +295,9 @@ describe( 'HeatmapChart', () => {
295
295
  </GlobalChartsProvider>
296
296
  );
297
297
  const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
298
- expect( grid.style.getPropertyValue( '--heatmap-cell-gap' ) ).toBe( '3px' );
298
+ expect( grid.style.getPropertyValue( '--a8c-charts-dimension-heatmap-cell-gap' ) ).toBe(
299
+ '3px'
300
+ );
299
301
  } );
300
302
 
301
303
  test( 'sizes compact cells to the theme compactCellSize', () => {
@@ -305,9 +307,13 @@ describe( 'HeatmapChart', () => {
305
307
  </GlobalChartsProvider>
306
308
  );
307
309
  const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
308
- expect( grid.style.getPropertyValue( '--heatmap-cell-size' ) ).toBe( '20px' );
310
+ expect( grid.style.getPropertyValue( '--a8c-charts-dimension-heatmap-cell-size' ) ).toBe(
311
+ '20px'
312
+ );
309
313
  // Compact track template is built from the fixed cell size.
310
- expect( grid.style.gridTemplateColumns ).toContain( 'var(--heatmap-cell-size)' );
314
+ expect( grid.style.gridTemplateColumns ).toContain(
315
+ 'var(--a8c-charts-dimension-heatmap-cell-size)'
316
+ );
311
317
  } );
312
318
 
313
319
  test( 'caps cell width without changing the normal vertical layout', () => {
@@ -343,7 +349,7 @@ describe( 'HeatmapChart', () => {
343
349
  test( 'applies the primaryColor prop as the cell-scale color', () => {
344
350
  renderChart( { primaryColor: '#abcdef' } );
345
351
  const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
346
- expect( grid.style.getPropertyValue( '--heatmap-primary' ) ).toBe( '#abcdef' );
352
+ expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe( '#abcdef' );
347
353
  } );
348
354
 
349
355
  test( 'resolves primaryColor from the chart theme', () => {
@@ -353,7 +359,7 @@ describe( 'HeatmapChart', () => {
353
359
  </GlobalChartsProvider>
354
360
  );
355
361
  const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
356
- expect( grid.style.getPropertyValue( '--heatmap-primary' ) ).toBe( '#0a0b0c' );
362
+ expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe( '#0a0b0c' );
357
363
  } );
358
364
 
359
365
  test( 'falls back to the palette colors[0] when no prop or theme primaryColor is set', () => {
@@ -363,7 +369,7 @@ describe( 'HeatmapChart', () => {
363
369
  </GlobalChartsProvider>
364
370
  );
365
371
  const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
366
- expect( grid.style.getPropertyValue( '--heatmap-primary' ) ).toBe( '#0a0b0c' );
372
+ expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe( '#0a0b0c' );
367
373
  } );
368
374
 
369
375
  test( 'the unresponsive export pins explicit width and height', () => {
@@ -1 +1,2 @@
1
+ export { useFittedRowCount, SUBPIXEL_TOLERANCE } from './use-fitted-row-count';
1
2
  export { useLeaderboardLegendItems } from './use-leaderboard-legend-items';
@@ -0,0 +1,115 @@
1
+ import { useCallback, useLayoutEffect, useRef, useState } from 'react';
2
+
3
+ /**
4
+ * Fractional layout rounding can put a row's bottom a hair past the container's
5
+ * without any visible clipping. Allow that much slack before hiding a row.
6
+ *
7
+ * Exported so tests and stories assert against the real tolerance rather than
8
+ * restating it.
9
+ */
10
+ export const SUBPIXEL_TOLERANCE = 0.5;
11
+
12
+ /**
13
+ * Counts how many leading rows fit inside the content container.
14
+ *
15
+ * Rows are read from the DOM rather than derived from constants: row height
16
+ * depends on the theme's row gap, label wrapping, and whatever a caller renders
17
+ * as a label, so any restated number would drift.
18
+ *
19
+ * Every row is a direct grid child marked with `data-row-index`; interactive rows
20
+ * use a button wrapper and non-interactive rows use a div wrapper.
21
+ *
22
+ * @param enabled - Whether to measure at all. When false every row fits.
23
+ * @param rowCount - Total number of rows rendered.
24
+ * @param data - Rendered row data, whose geometry may change without changing the count.
25
+ * @return Ref for the content container and the number of leading rows that fit.
26
+ */
27
+ export function useFittedRowCount( enabled: boolean, rowCount: number, data: unknown ) {
28
+ const contentRef = useRef< HTMLDivElement | null >( null );
29
+ const [ fittedCount, setFittedCount ] = useState( rowCount );
30
+ const [ isMeasurable, setIsMeasurable ] = useState( true );
31
+
32
+ const measure = useCallback( () => {
33
+ const content = contentRef.current;
34
+ if ( ! content ) {
35
+ return;
36
+ }
37
+
38
+ // Switching from the default scrollable mode can leave the content at a
39
+ // non-zero scroll offset, which would shift every measurement below.
40
+ content.scrollTop = 0;
41
+
42
+ const rows = content.querySelectorAll< HTMLElement >(
43
+ ':scope > [data-leaderboard-grid] > [data-row-index]'
44
+ );
45
+
46
+ const rowBottoms: number[] = [];
47
+ rows.forEach( row => {
48
+ const index = Number( row.getAttribute( 'data-row-index' ) );
49
+ // An unparseable index would write a non-numeric key, leaving a hole
50
+ // that silently truncates the scan below at the preceding row.
51
+ if ( ! Number.isInteger( index ) || index < 0 || index >= rowCount ) {
52
+ return;
53
+ }
54
+ const { bottom } = row.getBoundingClientRect();
55
+ rowBottoms[ index ] = Math.max( rowBottoms[ index ] ?? -Infinity, bottom );
56
+ } );
57
+
58
+ // Fail open. If the rows cannot be measured at all — a changed DOM shape,
59
+ // a detached container — falling back to the scrollable default keeps the
60
+ // data reachable, where hiding every row reads as a broken tile.
61
+ if ( rowBottoms.length === 0 ) {
62
+ setIsMeasurable( false );
63
+ setFittedCount( rowCount );
64
+ return;
65
+ }
66
+ setIsMeasurable( true );
67
+
68
+ const contentBottom = content.getBoundingClientRect().bottom + SUBPIXEL_TOLERANCE;
69
+
70
+ let fits = 0;
71
+ while ( fits < rowBottoms.length && rowBottoms[ fits ] <= contentBottom ) {
72
+ fits++;
73
+ }
74
+
75
+ // Bail before setState: setState inside a ResizeObserver callback is the
76
+ // usual way this grows a render loop.
77
+ setFittedCount( current => ( current === fits ? current : fits ) );
78
+ }, [ rowCount ] );
79
+
80
+ // Measure after row data changes so geometry updates are caught even when the
81
+ // row count and the grid's overall size stay the same (for example, one label
82
+ // wraps while another unwraps after a same-length data update).
83
+ useLayoutEffect( () => {
84
+ if ( ! enabled ) {
85
+ setFittedCount( rowCount );
86
+ return;
87
+ }
88
+
89
+ measure();
90
+ }, [ enabled, rowCount, data, measure ] );
91
+
92
+ useLayoutEffect( () => {
93
+ if ( ! enabled ) {
94
+ return;
95
+ }
96
+
97
+ const content = contentRef.current;
98
+ if ( ! content ) {
99
+ return;
100
+ }
101
+
102
+ const observer = new ResizeObserver( measure );
103
+ // The container catches tile resizing; the grid catches font, image,
104
+ // theme, or label changes that alter row geometry on an unchanged tile.
105
+ observer.observe( content );
106
+ const grid = content.querySelector< HTMLElement >( ':scope > [data-leaderboard-grid]' );
107
+ if ( grid ) {
108
+ observer.observe( grid );
109
+ }
110
+
111
+ return () => observer.disconnect();
112
+ }, [ enabled, rowCount, measure ] );
113
+
114
+ return { contentRef, fittedCount: enabled ? fittedCount : rowCount, isMeasurable };
115
+ }
@@ -16,6 +16,15 @@
16
16
  flex: 1;
17
17
  min-height: 0;
18
18
  overflow: auto;
19
+
20
+ // With fitRows the chart hides the rows that would not fit, so there is
21
+ // nothing left to scroll to — a scrollbar here would only ever reveal the
22
+ // hidden rows' reserved space.
23
+ &--fit {
24
+ overflow: hidden;
25
+ // Anchors .fitEmptyState, which is laid over the hidden rows.
26
+ position: relative;
27
+ }
19
28
  }
20
29
  }
21
30
 
@@ -51,7 +60,7 @@
51
60
  // row's fixed-pixel hover inset from collapsing a narrow bar to nothing.
52
61
  min-width: var(--wpds-dimension-size-5xs, 4px);
53
62
 
54
- border-radius: var(--a8c--charts--leaderboard--bar--border-radius, 9999px);
63
+ border-radius: var(--a8c-charts-border-radius-leaderboard-bar, var(--a8c--charts--leaderboard--bar--border-radius, 9999px));
55
64
  z-index: -1; /* places it behind the label */
56
65
 
57
66
  &--animated {
@@ -95,14 +104,32 @@
95
104
  .emptyState {
96
105
  padding: var(--wpds-dimension-padding-3xl, 32px) var(--wpds-dimension-padding-lg, 16px);
97
106
  text-align: center;
98
- color: var(--wpds-color-foreground-content-neutral-weak, #707070);
107
+ color: var(--a8c-charts-color-label-secondary, var(--wpds-color-foreground-content-neutral-weak, #707070));
99
108
  font-size: var(--wpds-typography-font-size-md, 13px);
100
109
  font-style: italic;
101
110
  }
102
111
 
103
- .row {
104
- --focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-focus, 2px));
112
+ // Shown when not even one row fits. The rows stay in the layout underneath so
113
+ // they remain measurable, so this is taken out of flow to sit over them.
114
+ .fitEmptyState {
115
+ position: absolute;
116
+ inset: 0;
117
+ display: flex;
118
+ align-items: center;
119
+ justify-content: center;
120
+ // The shared empty state reserves generous vertical whitespace. This overlay
121
+ // is specifically for compact containers, where the text may fit even though
122
+ // a complete data row does not.
123
+ padding-block: 0;
124
+ }
105
125
 
126
+ // Focus-ring width shared so a standard row's reserved padding and an
127
+ // interactive row's outline (and its inset offset) stay equal. A Sass var,
128
+ // not a custom property: a compile-time constant scoped to this module, so it
129
+ // needs no runtime cascade and never lands on the DOM as an unprefixed var.
130
+ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-focus, 2px));
131
+
132
+ .row {
106
133
  display: grid;
107
134
  grid-column: 1 / -1;
108
135
  grid-template-columns: subgrid;
@@ -110,7 +137,7 @@
110
137
  // Every row reserves the same space for the interactive focus ring. Keeping
111
138
  // this on the shared wrapper ensures that adding or removing an action never
112
139
  // changes the row height or subgrid column edges.
113
- padding: var(--focus-ring-width);
140
+ padding: $focus-ring-width;
114
141
  }
115
142
 
116
143
  .interactiveRow {
@@ -153,14 +180,14 @@
153
180
 
154
181
  // Fixed reserve for the chevron — the value slides by it in full, and the
155
182
  // bar width inset (see leaderboard-chart.tsx) scales it by the bar's share.
156
- --a8c--charts--leaderboard--bar--hover-inset: var(--wpds-dimension-gap-2xl, 32px);
183
+ --a8c-charts-dimension-leaderboard-bar-hover-inset: var(--wpds-dimension-gap-2xl, 32px);
157
184
 
158
185
  .bar {
159
186
  opacity: 0.5;
160
187
  }
161
188
 
162
189
  .valueContainer {
163
- transform: translateX(calc(var(--a8c--charts--leaderboard--bar--hover-inset) * -1));
190
+ transform: translateX(calc(var(--a8c-charts-dimension-leaderboard-bar-hover-inset) * -1));
164
191
  }
165
192
 
166
193
  .chevron {
@@ -171,18 +198,18 @@
171
198
  // In RTL the value slides toward the inline start the other way (rightward).
172
199
  [dir="rtl"] &:hover .valueContainer,
173
200
  [dir="rtl"] &:focus-visible .valueContainer {
174
- transform: translateX(var(--a8c--charts--leaderboard--bar--hover-inset));
201
+ transform: translateX(var(--a8c-charts-dimension-leaderboard-bar-hover-inset));
175
202
  }
176
203
 
177
204
  &:focus-visible {
178
205
  border-radius: var(--wpds-border-radius-sm, 2px);
179
206
  outline:
180
- var(--focus-ring-width) solid
181
- var(--wpds-color-stroke-focus, var(--wp-admin-theme-color, #3858e9));
207
+ $focus-ring-width solid
208
+ var(--a8c-charts-color-focus, var(--wpds-color-stroke-focus, var(--wp-admin-theme-color, #3858e9)));
182
209
  // Inset by its own width so the outer edge hugs the row boundary: the
183
210
  // outline then sits in the shared row padding, clear of the bar, and stays
184
211
  // inside the overflow:auto container that would clip an outset ring.
185
- outline-offset: calc(-1 * var(--focus-ring-width));
212
+ outline-offset: calc(-1 * #{$focus-ring-width});
186
213
  }
187
214
  }
188
215
 
@@ -193,7 +220,7 @@
193
220
  bottom: 0;
194
221
  margin-block: auto;
195
222
  opacity: 0;
196
- color: var(--wpds-color-foreground-content-neutral-weak, #707070);
223
+ color: var(--a8c-charts-color-label-secondary, var(--wpds-color-foreground-content-neutral-weak, #707070));
197
224
  transition:
198
225
  opacity var(--wpds-motion-duration-sm, 100ms)
199
226
  var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
@@ -20,7 +20,7 @@ import { useChartChildren } from '../private/chart-composition';
20
20
  import { ChartLayout } from '../private/chart-layout';
21
21
  import { SingleChartContext } from '../private/single-chart-context';
22
22
  import { withResponsive } from '../private/with-responsive';
23
- import { useLeaderboardLegendItems } from './hooks';
23
+ import { useFittedRowCount, useLeaderboardLegendItems } from './hooks';
24
24
  import styles from './leaderboard-chart.module.scss';
25
25
  import type { LeaderboardChartProps } from './types';
26
26
  import type { LeaderboardEntry } from '../../types';
@@ -62,15 +62,22 @@ const defaultDeltaFormatter = ( value: number ): string => {
62
62
  * @return A CSS width value.
63
63
  */
64
64
  const getBarWidth = ( share: number ): string =>
65
- `calc(${ share }% - var(--a8c--charts--leaderboard--bar--hover-inset, 0px) * ${ share } / 100)`;
65
+ `calc(${ share }% - var(--a8c-charts-dimension-leaderboard-bar-hover-inset, 0px) * ${ share } / 100)`;
66
66
 
67
- const hasComparisonValue = (
67
+ const hasPreviousValue = (
68
+ entry: LeaderboardEntry
69
+ ): entry is LeaderboardEntry & {
70
+ previousValue: number;
71
+ previousShare: number;
72
+ } => entry.previousValue != null && entry.previousShare != null;
73
+
74
+ const hasDeltaValue = (
68
75
  entry: LeaderboardEntry
69
76
  ): entry is LeaderboardEntry & {
70
77
  previousValue: number;
71
78
  previousShare: number;
72
79
  delta: number;
73
- } => entry.previousValue != null && entry.previousShare != null && entry.delta != null;
80
+ } => hasPreviousValue( entry ) && entry.delta != null;
74
81
 
75
82
  const BarLabel = ( { label }: { label: LeaderboardEntry[ 'label' ] } ) => (
76
83
  <>{ typeof label === 'string' ? <Text className={ styles.label }>{ label }</Text> : label }</>
@@ -117,7 +124,7 @@ const BarWithLabel = ( {
117
124
  ></div>
118
125
  ) }
119
126
 
120
- { showComparisonBar && hasComparisonValue( entry ) && (
127
+ { showComparisonBar && hasPreviousValue( entry ) && (
121
128
  <div
122
129
  className={ clsx( styles.bar, {
123
130
  [ styles[ 'bar--animated' ] ]: animation,
@@ -148,6 +155,7 @@ const BarWithLabel = ( {
148
155
  * @param props.valueFormatter - Custom formatter for values
149
156
  * @param props.deltaFormatter - Custom formatter for delta values
150
157
  * @param props.loading - Whether the chart is in loading state
158
+ * @param props.fitRows - Whether to show only the rows that fit the chart's height
151
159
  * @param props.animation - Whether the chart should animate on load
152
160
  * @param props.showLegend - Whether to show legend
153
161
  * @param props.legend - Legend configuration (orientation, position, alignment, shape, shapeStyles, interactive)
@@ -171,6 +179,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
171
179
  deltaFormatter = defaultDeltaFormatter,
172
180
  animation,
173
181
  loading = false,
182
+ fitRows = false,
174
183
  showLegend = false,
175
184
  legend = {},
176
185
  legendLabels,
@@ -269,6 +278,16 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
269
278
 
270
279
  const prefersReducedMotion = usePrefersReducedMotion();
271
280
 
281
+ // There are no rows to measure while an interactive legend has hidden every
282
+ // series. Pausing fitting restores the full row count and, when a series is shown
283
+ // again, re-runs the effect against the newly mounted grid.
284
+ const { contentRef, fittedCount, isMeasurable } = useFittedRowCount(
285
+ fitRows && ! allSeriesHidden,
286
+ data?.length ?? 0,
287
+ data
288
+ );
289
+ const shouldFitRows = fitRows && isMeasurable;
290
+
272
291
  // Handle empty or undefined data
273
292
  if ( ! data || data.length === 0 ) {
274
293
  return (
@@ -338,18 +357,43 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
338
357
  data-testid="leaderboard-chart-container"
339
358
  trailingContent={ nonLegendChildren }
340
359
  >
341
- <div className={ styles.leaderboardChart__content }>
360
+ <div
361
+ ref={ contentRef }
362
+ data-testid="leaderboard-chart-content"
363
+ className={ clsx( styles.leaderboardChart__content, {
364
+ [ styles[ 'leaderboardChart__content--fit' ] ]: shouldFitRows,
365
+ } ) }
366
+ >
367
+ { shouldFitRows && fittedCount === 0 && ! allSeriesHidden && (
368
+ // Overlaid, not swapped in: the rows must stay laid out to stay measurable.
369
+ <div className={ clsx( styles.emptyState, styles.fitEmptyState ) }>
370
+ { __( 'Not enough space to display data', 'jetpack-charts' ) }
371
+ </div>
372
+ ) }
342
373
  { allSeriesHidden ? (
343
374
  <div className={ styles.emptyState }>
344
375
  { __( 'All series are hidden. Click legend items to show data.', 'jetpack-charts' ) }
345
376
  </div>
346
377
  ) : (
347
- <Grid templateColumns="minmax(0, 1fr) auto" rowGap={ rowGap } columnGap={ columnGap }>
348
- { data.map( entry => {
378
+ <Grid
379
+ templateColumns="minmax(0, 1fr) auto"
380
+ rowGap={ rowGap }
381
+ columnGap={ columnGap }
382
+ data-leaderboard-grid
383
+ >
384
+ { data.map( ( entry, rowIndex ) => {
385
+ // visibility, not clipping: hidden rows keep their geometry (so a
386
+ // taller container can reveal them) but leave hit testing, focus,
387
+ // and the accessibility tree.
388
+ const rowStyle =
389
+ shouldFitRows && rowIndex >= fittedCount
390
+ ? ( { visibility: 'hidden' } as const )
391
+ : undefined;
349
392
  const showComparisonColumn = withComparison && isComparisonVisible;
350
- const hasDeltaValue = hasComparisonValue( entry );
351
- const showComparisonValue = showComparisonColumn && hasDeltaValue;
352
- const showComparisonPlaceholder = showComparisonColumn && ! hasDeltaValue;
393
+ const hasPreviousPeriodValue = hasPreviousValue( entry );
394
+ const hasDelta = hasDeltaValue( entry );
395
+ const showComparisonValue = showComparisonColumn && hasDelta;
396
+ const showComparisonPlaceholder = showComparisonColumn && ! hasDelta;
353
397
  const colorIndex = showComparisonValue ? Math.sign( entry.delta ) + 1 : 1;
354
398
  const deltaColor = deltaColors[ colorIndex ];
355
399
 
@@ -388,9 +432,11 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
388
432
  className={ clsx( styles.deltaValue, styles.deltaPlaceholder ) }
389
433
  style={ { color: deltaColor } }
390
434
  >
391
- <span aria-hidden="true">-</span>
435
+ <span aria-hidden="true">—</span>
392
436
  <VisuallyHidden as="span">
393
- { __( 'No comparison data', 'jetpack-charts' ) }
437
+ { hasPreviousPeriodValue
438
+ ? __( 'Percentage change unavailable', 'jetpack-charts' )
439
+ : __( 'No comparison data', 'jetpack-charts' ) }
394
440
  </VisuallyHidden>
395
441
  </Text>
396
442
  ) }
@@ -403,6 +449,8 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
403
449
  <button
404
450
  key={ entry.id }
405
451
  type="button"
452
+ data-row-index={ rowIndex }
453
+ style={ rowStyle }
406
454
  className={ clsx( styles.row, styles.interactiveRow ) }
407
455
  onClick={ entry.onClick }
408
456
  aria-label={ entry.ariaLabel }
@@ -414,7 +462,12 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
414
462
  }
415
463
 
416
464
  return (
417
- <div key={ entry.id } className={ styles.row }>
465
+ <div
466
+ key={ entry.id }
467
+ data-row-index={ rowIndex }
468
+ style={ rowStyle }
469
+ className={ styles.row }
470
+ >
418
471
  { rowCells }
419
472
  </div>
420
473
  );