@automattic/charts 1.10.2 → 1.11.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 (30) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/TOKENS.md +72 -0
  3. package/dist/index.cjs +149 -44
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +60 -47
  6. package/dist/index.d.cts +17 -7
  7. package/dist/index.d.ts +17 -7
  8. package/dist/index.js +150 -45
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +7 -7
  12. package/src/charts/heatmap-chart/heatmap-chart.module.scss +18 -13
  13. package/src/charts/heatmap-chart/heatmap-chart.tsx +11 -7
  14. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +3 -3
  15. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +13 -7
  16. package/src/charts/leaderboard-chart/hooks/index.ts +1 -0
  17. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +115 -0
  18. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +47 -20
  19. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +72 -16
  20. package/src/charts/leaderboard-chart/test/leaderboard-chart-fit-rows.test.tsx +365 -0
  21. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +73 -2
  22. package/src/charts/leaderboard-chart/types.ts +12 -0
  23. package/src/charts/line-chart/line-chart.module.scss +2 -2
  24. package/src/charts/private/grid-control/grid-control.module.scss +1 -1
  25. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  26. package/src/charts/private/x-zoom.module.scss +12 -10
  27. package/src/components/tooltip/base-tooltip.module.scss +3 -2
  28. package/src/components/trend-indicator/trend-indicator.module.scss +6 -5
  29. package/src/providers/chart-context/themes.ts +33 -19
  30. package/src/types.ts +2 -2
@@ -1,6 +1,5 @@
1
1
  /* eslint-disable @wordpress/no-unsafe-wp-apis */
2
2
  import { __experimentalGrid as Grid, VisuallyHidden } from '@wordpress/components';
3
- import { Fragment } from '@wordpress/element';
4
3
  import { __ } from '@wordpress/i18n';
5
4
  import { Icon, chevronRight } from '@wordpress/icons';
6
5
  import { Stack, Text } from '@wordpress/ui';
@@ -21,7 +20,7 @@ import { useChartChildren } from '../private/chart-composition';
21
20
  import { ChartLayout } from '../private/chart-layout';
22
21
  import { SingleChartContext } from '../private/single-chart-context';
23
22
  import { withResponsive } from '../private/with-responsive';
24
- import { useLeaderboardLegendItems } from './hooks';
23
+ import { useFittedRowCount, useLeaderboardLegendItems } from './hooks';
25
24
  import styles from './leaderboard-chart.module.scss';
26
25
  import type { LeaderboardChartProps } from './types';
27
26
  import type { LeaderboardEntry } from '../../types';
@@ -63,15 +62,22 @@ const defaultDeltaFormatter = ( value: number ): string => {
63
62
  * @return A CSS width value.
64
63
  */
65
64
  const getBarWidth = ( share: number ): string =>
66
- `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)`;
67
66
 
68
- 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 = (
69
75
  entry: LeaderboardEntry
70
76
  ): entry is LeaderboardEntry & {
71
77
  previousValue: number;
72
78
  previousShare: number;
73
79
  delta: number;
74
- } => entry.previousValue != null && entry.previousShare != null && entry.delta != null;
80
+ } => hasPreviousValue( entry ) && entry.delta != null;
75
81
 
76
82
  const BarLabel = ( { label }: { label: LeaderboardEntry[ 'label' ] } ) => (
77
83
  <>{ typeof label === 'string' ? <Text className={ styles.label }>{ label }</Text> : label }</>
@@ -118,7 +124,7 @@ const BarWithLabel = ( {
118
124
  ></div>
119
125
  ) }
120
126
 
121
- { showComparisonBar && hasComparisonValue( entry ) && (
127
+ { showComparisonBar && hasPreviousValue( entry ) && (
122
128
  <div
123
129
  className={ clsx( styles.bar, {
124
130
  [ styles[ 'bar--animated' ] ]: animation,
@@ -149,6 +155,7 @@ const BarWithLabel = ( {
149
155
  * @param props.valueFormatter - Custom formatter for values
150
156
  * @param props.deltaFormatter - Custom formatter for delta values
151
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
152
159
  * @param props.animation - Whether the chart should animate on load
153
160
  * @param props.showLegend - Whether to show legend
154
161
  * @param props.legend - Legend configuration (orientation, position, alignment, shape, shapeStyles, interactive)
@@ -172,6 +179,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
172
179
  deltaFormatter = defaultDeltaFormatter,
173
180
  animation,
174
181
  loading = false,
182
+ fitRows = false,
175
183
  showLegend = false,
176
184
  legend = {},
177
185
  legendLabels,
@@ -270,6 +278,16 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
270
278
 
271
279
  const prefersReducedMotion = usePrefersReducedMotion();
272
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
+
273
291
  // Handle empty or undefined data
274
292
  if ( ! data || data.length === 0 ) {
275
293
  return (
@@ -339,18 +357,43 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
339
357
  data-testid="leaderboard-chart-container"
340
358
  trailingContent={ nonLegendChildren }
341
359
  >
342
- <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
+ ) }
343
373
  { allSeriesHidden ? (
344
374
  <div className={ styles.emptyState }>
345
375
  { __( 'All series are hidden. Click legend items to show data.', 'jetpack-charts' ) }
346
376
  </div>
347
377
  ) : (
348
- <Grid templateColumns="minmax(0, 1fr) auto" rowGap={ rowGap } columnGap={ columnGap }>
349
- { 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;
350
392
  const showComparisonColumn = withComparison && isComparisonVisible;
351
- const hasDeltaValue = hasComparisonValue( entry );
352
- const showComparisonValue = showComparisonColumn && hasDeltaValue;
353
- const showComparisonPlaceholder = showComparisonColumn && ! hasDeltaValue;
393
+ const hasPreviousPeriodValue = hasPreviousValue( entry );
394
+ const hasDelta = hasDeltaValue( entry );
395
+ const showComparisonValue = showComparisonColumn && hasDelta;
396
+ const showComparisonPlaceholder = showComparisonColumn && ! hasDelta;
354
397
  const colorIndex = showComparisonValue ? Math.sign( entry.delta ) + 1 : 1;
355
398
  const deltaColor = deltaColors[ colorIndex ];
356
399
 
@@ -389,9 +432,11 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
389
432
  className={ clsx( styles.deltaValue, styles.deltaPlaceholder ) }
390
433
  style={ { color: deltaColor } }
391
434
  >
392
- <span aria-hidden="true">-</span>
435
+ <span aria-hidden="true">—</span>
393
436
  <VisuallyHidden as="span">
394
- { __( 'No comparison data', 'jetpack-charts' ) }
437
+ { hasPreviousPeriodValue
438
+ ? __( 'Percentage change unavailable', 'jetpack-charts' )
439
+ : __( 'No comparison data', 'jetpack-charts' ) }
395
440
  </VisuallyHidden>
396
441
  </Text>
397
442
  ) }
@@ -404,7 +449,9 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
404
449
  <button
405
450
  key={ entry.id }
406
451
  type="button"
407
- className={ styles.interactiveRow }
452
+ data-row-index={ rowIndex }
453
+ style={ rowStyle }
454
+ className={ clsx( styles.row, styles.interactiveRow ) }
408
455
  onClick={ entry.onClick }
409
456
  aria-label={ entry.ariaLabel }
410
457
  >
@@ -414,7 +461,16 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
414
461
  );
415
462
  }
416
463
 
417
- return <Fragment key={ entry.id }>{ rowCells }</Fragment>;
464
+ return (
465
+ <div
466
+ key={ entry.id }
467
+ data-row-index={ rowIndex }
468
+ style={ rowStyle }
469
+ className={ styles.row }
470
+ >
471
+ { rowCells }
472
+ </div>
473
+ );
418
474
  } ) }
419
475
  </Grid>
420
476
  ) }
@@ -0,0 +1,365 @@
1
+ import { act, render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { SUBPIXEL_TOLERANCE } from '../hooks';
4
+ import { LeaderboardChartUnresponsive as LeaderboardChart } from '../leaderboard-chart';
5
+ import type { LeaderboardEntry } from '../../../types';
6
+
7
+ const ROW_HEIGHT = 40;
8
+
9
+ const makeData = ( count: number ): LeaderboardEntry[] =>
10
+ Array.from( { length: count }, ( _, index ) => ( {
11
+ id: `row-${ index }`,
12
+ label: `Row ${ index }`,
13
+ currentValue: 100 - index,
14
+ currentShare: 100 - index * 10,
15
+ } ) );
16
+
17
+ /**
18
+ * Give the content container a height and lay every marked row out in a uniform
19
+ * stack beneath it. JSDOM performs no layout, so the geometry the component
20
+ * measures has to be supplied here.
21
+ *
22
+ * The height is mutable and `resizeTo` re-fires the observed callbacks, because
23
+ * a fixed height would let the whole ResizeObserver path be deleted with the
24
+ * suite still green.
25
+ *
26
+ * @param contentHeight - Starting height of the chart's content container.
27
+ * @param getRowBottom - Returns the bottom edge for a row index.
28
+ * @return Handle for resizing the container and restoring the globals.
29
+ */
30
+ const mockLayout = (
31
+ contentHeight: number,
32
+ getRowBottom: ( rowIndex: number ) => number = rowIndex => ( rowIndex + 1 ) * ROW_HEIGHT
33
+ ) => {
34
+ const originalGetRect = Element.prototype.getBoundingClientRect;
35
+ const originalResizeObserver = globalThis.ResizeObserver;
36
+ const box = { height: contentHeight };
37
+ const callbacks = new Set< ResizeObserverCallback >();
38
+
39
+ Element.prototype.getBoundingClientRect = function () {
40
+ if ( this.classList.contains( 'leaderboardChart__content' ) ) {
41
+ return { top: 0, bottom: box.height, height: box.height } as DOMRect;
42
+ }
43
+
44
+ const rowIndex = this.getAttribute?.( 'data-row-index' );
45
+ if ( rowIndex !== null && rowIndex !== undefined ) {
46
+ const bottom = getRowBottom( Number( rowIndex ) );
47
+ return { top: bottom - ROW_HEIGHT, bottom, height: ROW_HEIGHT } as DOMRect;
48
+ }
49
+
50
+ return originalGetRect.call( this );
51
+ };
52
+
53
+ globalThis.ResizeObserver = class {
54
+ #callback: ResizeObserverCallback;
55
+
56
+ constructor( callback: ResizeObserverCallback ) {
57
+ this.#callback = callback;
58
+ }
59
+
60
+ observe() {
61
+ callbacks.add( this.#callback );
62
+ }
63
+
64
+ unobserve() {}
65
+
66
+ disconnect() {
67
+ callbacks.delete( this.#callback );
68
+ }
69
+ } as unknown as typeof globalThis.ResizeObserver;
70
+
71
+ return {
72
+ observerCount: () => callbacks.size,
73
+ resizeTo: ( height: number ) =>
74
+ act( () => {
75
+ box.height = height;
76
+ callbacks.forEach( callback => callback( [], {} as ResizeObserver ) );
77
+ } ),
78
+ restore: () => {
79
+ Element.prototype.getBoundingClientRect = originalGetRect;
80
+ globalThis.ResizeObserver = originalResizeObserver;
81
+ },
82
+ };
83
+ };
84
+
85
+ describe( 'LeaderboardChart fitRows', () => {
86
+ let layout: ReturnType< typeof mockLayout >;
87
+
88
+ afterEach( () => {
89
+ layout?.restore();
90
+ } );
91
+
92
+ it( 'hides rows that do not fit the content height', () => {
93
+ // 100px of content fits two whole 40px rows; the third would be clipped.
94
+ layout = mockLayout( 100 );
95
+
96
+ render( <LeaderboardChart data={ makeData( 5 ) } fitRows /> );
97
+
98
+ expect( screen.getByText( 'Row 0' ) ).toBeVisible();
99
+ expect( screen.getByText( 'Row 1' ) ).toBeVisible();
100
+ expect( screen.getByText( 'Row 2' ) ).not.toBeVisible();
101
+ expect( screen.getByText( 'Row 3' ) ).not.toBeVisible();
102
+ expect( screen.getByText( 'Row 4' ) ).not.toBeVisible();
103
+ } );
104
+
105
+ it( 'shows every row when they fit exactly', () => {
106
+ // Boundary case for the `<=` in the fit scan: the last row's bottom edge
107
+ // lands exactly on the container's.
108
+ layout = mockLayout( 5 * ROW_HEIGHT );
109
+
110
+ render( <LeaderboardChart data={ makeData( 5 ) } fitRows /> );
111
+
112
+ expect( screen.getByText( 'Row 4' ) ).toBeVisible();
113
+ } );
114
+
115
+ it( 'treats a row ending within the subpixel tolerance as fitting', () => {
116
+ // Row 1's bottom is 80. Heights are derived from the real tolerance so
117
+ // retuning it does not fail this test for the wrong reason.
118
+ const rowBottom = 2 * ROW_HEIGHT;
119
+ layout = mockLayout( rowBottom - SUBPIXEL_TOLERANCE + 0.1 );
120
+
121
+ render( <LeaderboardChart data={ makeData( 3 ) } fitRows /> );
122
+
123
+ expect( screen.getByText( 'Row 1' ) ).toBeVisible();
124
+
125
+ // A hair past the tolerance, so the row is genuinely clipped.
126
+ layout.resizeTo( rowBottom - SUBPIXEL_TOLERANCE - 0.1 );
127
+
128
+ expect( screen.getByText( 'Row 1' ) ).not.toBeVisible();
129
+ } );
130
+
131
+ it( 'reveals rows when the container grows and hides them when it shrinks', () => {
132
+ // Hiding rows is only acceptable because a taller container brings them
133
+ // back, so the round trip is the property worth pinning — not the first
134
+ // render, which a one-shot measurement would also satisfy.
135
+ layout = mockLayout( 100 );
136
+
137
+ render( <LeaderboardChart data={ makeData( 5 ) } fitRows /> );
138
+ expect( screen.getByText( 'Row 2' ) ).not.toBeVisible();
139
+
140
+ layout.resizeTo( 5 * ROW_HEIGHT );
141
+ expect( screen.getByText( 'Row 4' ) ).toBeVisible();
142
+
143
+ layout.resizeTo( 60 );
144
+ expect( screen.getByText( 'Row 1' ) ).not.toBeVisible();
145
+
146
+ // Out through "nothing fits" and back again.
147
+ layout.resizeTo( 20 );
148
+ expect( screen.getByText( 'Not enough space to display data' ) ).toBeVisible();
149
+
150
+ layout.resizeTo( 5 * ROW_HEIGHT );
151
+ expect( screen.getByText( 'Row 4' ) ).toBeVisible();
152
+ expect( screen.queryByText( 'Not enough space to display data' ) ).not.toBeInTheDocument();
153
+ } );
154
+
155
+ it( 'explains itself when not even one row fits', () => {
156
+ // 20px of content cannot fit a 40px row. Hiding every row would leave a
157
+ // blank panel, which reads as a broken tile rather than an empty one.
158
+ layout = mockLayout( 20 );
159
+
160
+ render( <LeaderboardChart data={ makeData( 3 ) } fitRows /> );
161
+
162
+ expect( screen.getByText( 'Not enough space to display data' ) ).toBeVisible();
163
+ expect( screen.getByText( 'Row 0' ) ).not.toBeVisible();
164
+ } );
165
+
166
+ it( 'leaves every row visible when fitRows is off', () => {
167
+ // Default behaviour: a standalone chart scrolls rather than dropping rows.
168
+ layout = mockLayout( 100 );
169
+
170
+ render( <LeaderboardChart data={ makeData( 5 ) } /> );
171
+
172
+ expect( screen.getByText( 'Row 4' ) ).toBeVisible();
173
+ } );
174
+
175
+ it( 'restores every row when fitRows is turned off, and refits when it returns', () => {
176
+ layout = mockLayout( 100 );
177
+
178
+ const data = makeData( 5 );
179
+ const { rerender } = render( <LeaderboardChart data={ data } fitRows /> );
180
+ expect( screen.getByText( 'Row 4' ) ).not.toBeVisible();
181
+
182
+ rerender( <LeaderboardChart data={ data } fitRows={ false } /> );
183
+ expect( screen.getByText( 'Row 4' ) ).toBeVisible();
184
+
185
+ rerender( <LeaderboardChart data={ data } fitRows /> );
186
+ expect( screen.getByText( 'Row 4' ) ).not.toBeVisible();
187
+ } );
188
+
189
+ it( 'stops observing once fitting is disabled', () => {
190
+ layout = mockLayout( 100 );
191
+
192
+ const data = makeData( 5 );
193
+ const { rerender } = render( <LeaderboardChart data={ data } fitRows /> );
194
+ expect( layout.observerCount() ).toBeGreaterThan( 0 );
195
+
196
+ rerender( <LeaderboardChart data={ data } fitRows={ false } /> );
197
+ expect( layout.observerCount() ).toBe( 0 );
198
+ } );
199
+
200
+ it( 'counts rows the same way whether or not they are interactive', () => {
201
+ // Both row types use one grid-child wrapper; only its element type differs.
202
+ layout = mockLayout( 100 );
203
+
204
+ const data = makeData( 5 ).map( ( entry, index ) =>
205
+ index % 2 === 0 ? { ...entry, onClick: () => {} } : entry
206
+ );
207
+
208
+ render( <LeaderboardChart data={ data } fitRows /> );
209
+
210
+ expect( screen.getByText( 'Row 1' ) ).toBeVisible();
211
+ expect( screen.getByText( 'Row 2' ) ).not.toBeVisible();
212
+ } );
213
+
214
+ it( 'hides interactive rows that do not fit, not just their labels', () => {
215
+ layout = mockLayout( 100 );
216
+
217
+ const data = makeData( 5 ).map( entry => ( { ...entry, onClick: () => {} } ) );
218
+
219
+ render( <LeaderboardChart data={ data } fitRows /> );
220
+
221
+ const rows = screen.getAllByRole( 'button', { hidden: true } );
222
+ expect( rows[ 1 ] ).toBeVisible();
223
+ expect( rows[ 2 ] ).not.toBeVisible();
224
+
225
+ // The default role query runs the accessibility-tree computation, which
226
+ // treats `visibility: hidden` as inaccessible — so this asserts the rows
227
+ // that do not fit are absent from the a11y tree, not merely styled.
228
+ expect( screen.getAllByRole( 'button' ) ).toHaveLength( 2 );
229
+ } );
230
+
231
+ it( 'keeps hidden rows measurable so a taller container can reveal them', () => {
232
+ // The rows must stay in the layout even when none of them fit. Dropping
233
+ // them would leave nothing to measure, so the row count could never
234
+ // recover when the container grows.
235
+ layout = mockLayout( 20 );
236
+
237
+ render( <LeaderboardChart data={ makeData( 3 ) } fitRows /> );
238
+
239
+ expect( screen.getByText( 'Row 0' ) ).toBeInTheDocument();
240
+ expect( screen.getByText( 'Row 2' ) ).toBeInTheDocument();
241
+ } );
242
+
243
+ it( 'refits when the data prop grows and shrinks', () => {
244
+ layout = mockLayout( 100 );
245
+
246
+ const { rerender } = render( <LeaderboardChart data={ makeData( 2 ) } fitRows /> );
247
+ expect( screen.getByText( 'Row 1' ) ).toBeVisible();
248
+
249
+ rerender( <LeaderboardChart data={ makeData( 5 ) } fitRows /> );
250
+ expect( screen.getByText( 'Row 1' ) ).toBeVisible();
251
+ expect( screen.getByText( 'Row 2' ) ).not.toBeVisible();
252
+
253
+ rerender( <LeaderboardChart data={ makeData( 2 ) } fitRows /> );
254
+ expect( screen.getByText( 'Row 1' ) ).toBeVisible();
255
+ } );
256
+
257
+ it( 'remeasures when row geometry changes without changing the row count or grid size', () => {
258
+ let rowBottoms = [ 40, 80, 120 ];
259
+ layout = mockLayout( 100, rowIndex => rowBottoms[ rowIndex ] );
260
+
261
+ const { rerender } = render( <LeaderboardChart data={ makeData( 3 ) } fitRows /> );
262
+
263
+ expect( screen.getByText( 'Row 1' ) ).toBeVisible();
264
+
265
+ // Simulate a same-length data update where the second row grows while a
266
+ // later row shrinks, leaving the grid's overall height unchanged.
267
+ rowBottoms = [ 40, 110, 120 ];
268
+ rerender( <LeaderboardChart data={ makeData( 3 ) } fitRows /> );
269
+
270
+ expect( screen.getByText( 'Row 0' ) ).toBeVisible();
271
+ expect( screen.getByText( 'Row 1' ) ).not.toBeVisible();
272
+ } );
273
+
274
+ it( 'resets an existing scroll offset when fitting is enabled', () => {
275
+ layout = mockLayout( 100, rowIndex => {
276
+ const content = screen.getByTestId( 'leaderboard-chart-content' );
277
+ return ( rowIndex + 1 ) * ROW_HEIGHT - content.scrollTop;
278
+ } );
279
+
280
+ const data = makeData( 5 );
281
+ const { rerender } = render( <LeaderboardChart data={ data } /> );
282
+ const content = screen.getByTestId( 'leaderboard-chart-content' );
283
+ content.scrollTop = 80;
284
+
285
+ rerender( <LeaderboardChart data={ data } fitRows /> );
286
+
287
+ expect( content.scrollTop ).toBe( 0 );
288
+ expect( screen.getByText( 'Row 1' ) ).toBeVisible();
289
+ expect( screen.getByText( 'Row 2' ) ).not.toBeVisible();
290
+ } );
291
+
292
+ it( 'falls back to showing every row when no rows can be measured', () => {
293
+ // A changed DOM shape must degrade to the scrollable default rather than
294
+ // to an empty tile claiming there is no space.
295
+ layout = mockLayout( 100 );
296
+
297
+ render( <LeaderboardChart data={ makeData( 5 ) } fitRows /> );
298
+
299
+ const content = screen.getByTestId( 'leaderboard-chart-content' );
300
+ // Simulating the failure means reaching for the markers themselves — there
301
+ // is no user-facing handle for "the row markers went away".
302
+ // eslint-disable-next-line testing-library/no-node-access
303
+ const cells = content.querySelectorAll( '[data-row-index]' );
304
+ cells.forEach( cell => cell.removeAttribute( 'data-row-index' ) );
305
+
306
+ // resizeTo re-fires the observed callbacks inside act.
307
+ layout.resizeTo( 100 );
308
+
309
+ expect( screen.getByText( 'Row 4' ) ).toBeVisible();
310
+ expect( screen.queryByText( 'Not enough space to display data' ) ).not.toBeInTheDocument();
311
+ expect( content ).not.toHaveClass( 'leaderboardChart__content--fit' );
312
+ } );
313
+
314
+ it( 'remeasures rows when an interactive legend restores a hidden series', async () => {
315
+ layout = mockLayout( 100 );
316
+ const user = userEvent.setup();
317
+
318
+ const data = makeData( 3 ).map( entry => ( {
319
+ ...entry,
320
+ previousValue: entry.currentValue - 10,
321
+ previousShare: entry.currentShare - 10,
322
+ delta: 10,
323
+ } ) );
324
+
325
+ render(
326
+ <LeaderboardChart
327
+ data={ data }
328
+ fitRows
329
+ withComparison
330
+ showLegend
331
+ legend={ { interactive: true } }
332
+ />
333
+ );
334
+
335
+ await user.click(
336
+ screen.getByRole( 'button', {
337
+ name: 'Current period: visible. Toggle visibility.',
338
+ } )
339
+ );
340
+ await user.click(
341
+ screen.getByRole( 'button', {
342
+ name: 'Previous period: visible. Toggle visibility.',
343
+ } )
344
+ );
345
+
346
+ expect(
347
+ screen.getByText( 'All series are hidden. Click legend items to show data.' )
348
+ ).toBeVisible();
349
+
350
+ // Nothing is observed while no rows are mounted, so a late notification
351
+ // cannot record zero rows against the grid that replaces them.
352
+ expect( layout.observerCount() ).toBe( 0 );
353
+
354
+ await user.click(
355
+ screen.getByRole( 'button', {
356
+ name: 'Current period: hidden. Toggle visibility.',
357
+ } )
358
+ );
359
+
360
+ expect( screen.getByText( 'Row 0' ) ).toBeVisible();
361
+ expect( screen.getByText( 'Row 1' ) ).toBeVisible();
362
+ expect( screen.getByText( 'Row 2' ) ).not.toBeVisible();
363
+ expect( screen.queryByText( 'Not enough space to display data' ) ).not.toBeInTheDocument();
364
+ } );
365
+ } );
@@ -97,11 +97,58 @@ describe( 'LeaderboardChart', () => {
97
97
  expect( screen.getByText( '+25%' ) ).toBeInTheDocument();
98
98
  expect( screen.getByText( '-8%' ) ).toBeInTheDocument();
99
99
  expect( screen.getByText( 'New Source' ) ).toBeInTheDocument();
100
- expect( screen.getByText( '-' ) ).toHaveAttribute( 'aria-hidden', 'true' );
100
+ expect( screen.getByText( '—' ) ).toHaveAttribute( 'aria-hidden', 'true' );
101
101
  expect( screen.getByText( 'No comparison data' ) ).toBeInTheDocument();
102
102
  expect( screen.queryByText( '+100%' ) ).not.toBeInTheDocument();
103
103
  } );
104
104
 
105
+ it( 'shows an unavailable delta when a known previous value is zero', () => {
106
+ render(
107
+ <LeaderboardChart
108
+ data={ [
109
+ {
110
+ id: 'new',
111
+ label: 'New Source',
112
+ currentValue: 100,
113
+ previousValue: 0,
114
+ currentShare: 100,
115
+ previousShare: 0,
116
+ },
117
+ ] }
118
+ withComparison={ true }
119
+ />
120
+ );
121
+
122
+ expect( screen.getByText( '—' ) ).toHaveAttribute( 'aria-hidden', 'true' );
123
+ expect( screen.getByText( 'Percentage change unavailable' ) ).toBeInTheDocument();
124
+ expect( screen.queryByText( 'No comparison data' ) ).not.toBeInTheDocument();
125
+ expect( screen.queryByText( '+100%' ) ).not.toBeInTheDocument();
126
+ } );
127
+
128
+ it( 'still renders the comparison bar when the previous value is known but the delta is unavailable', () => {
129
+ // The comparison bar is gated on a known previous value, not on the
130
+ // delta, so a row whose delta is unavailable keeps its bar. previousShare
131
+ // is non-zero here so a dropped bar cannot hide behind a zero width.
132
+ const { container } = render(
133
+ <LeaderboardChart
134
+ data={ [
135
+ {
136
+ id: 'zero-prev',
137
+ label: 'Zero Prev',
138
+ currentValue: 100,
139
+ previousValue: 0,
140
+ currentShare: 100,
141
+ previousShare: 40,
142
+ },
143
+ ] }
144
+ withComparison={ true }
145
+ />
146
+ );
147
+
148
+ // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
149
+ expect( container.querySelectorAll( '.bar' ) ).toHaveLength( 2 );
150
+ } );
151
+
105
152
  it( 'shows custom label when provided', () => {
106
153
  render(
107
154
  <LeaderboardChart
@@ -440,7 +487,31 @@ describe( 'LeaderboardChart', () => {
440
487
  expect( screen.getByRole( 'button' ) ).toHaveAccessibleName(
441
488
  /New Source.*100.*No comparison data/
442
489
  );
443
- expect( screen.getByRole( 'button' ) ).not.toHaveAccessibleName( /-/ );
490
+ expect( screen.getByRole( 'button' ) ).not.toHaveAccessibleName( /—/ );
491
+ } );
492
+
493
+ it( 'does not include the unavailable delta dash in interactive row names', () => {
494
+ render(
495
+ <LeaderboardChart
496
+ data={ [
497
+ {
498
+ id: 'zero-prev',
499
+ label: 'Zero Prev',
500
+ currentValue: 100,
501
+ previousValue: 0,
502
+ currentShare: 1,
503
+ previousShare: 0,
504
+ onClick: jest.fn(),
505
+ },
506
+ ] }
507
+ withComparison={ true }
508
+ />
509
+ );
510
+
511
+ expect( screen.getByRole( 'button' ) ).toHaveAccessibleName(
512
+ /Zero Prev.*100.*Percentage change unavailable/
513
+ );
514
+ expect( screen.getByRole( 'button' ) ).not.toHaveAccessibleName( /—/ );
444
515
  } );
445
516
 
446
517
  it( 'derives the accessible name from an image label via its alt text', () => {
@@ -50,10 +50,22 @@ export interface LeaderboardChartProps
50
50
  */
51
51
  loading?: boolean;
52
52
 
53
+ /**
54
+ * Show only complete rows that fit the chart height instead of scrolling.
55
+ *
56
+ * Requires a container that constrains the chart's height; otherwise every
57
+ * row fits. Refits are debounced with the parent resize on the responsive
58
+ * export, so `LeaderboardChartUnresponsive` tracks a live drag more closely.
59
+ * @default false
60
+ */
61
+ fitRows?: boolean;
62
+
53
63
  /**
54
64
  * Custom styling for the chart container
55
65
  */
56
66
  style?: React.CSSProperties & {
67
+ '--a8c-charts-border-radius-leaderboard-bar'?: string;
68
+ /** @deprecated Use `--a8c-charts-border-radius-leaderboard-bar`. */
57
69
  '--a8c--charts--leaderboard--bar--border-radius'?: string;
58
70
  };
59
71
 
@@ -18,8 +18,8 @@
18
18
 
19
19
  &__tooltip,
20
20
  &__annotation-label-popover {
21
- background: var(--wpds-color-background-surface-neutral-strong, #fff);
22
- color: var(--wpds-color-foreground-content-neutral, #1e1e1e);
21
+ background: var(--a8c-charts-color-background, var(--wpds-color-background-surface-neutral-strong, #fff));
22
+ color: var(--a8c-charts-color-label, var(--wpds-color-foreground-content-neutral, #1e1e1e));
23
23
  padding: var(--wpds-dimension-padding-sm, 8px);
24
24
  }
25
25
 
@@ -1,7 +1,7 @@
1
1
  .grid-control {
2
2
 
3
3
  :global(.visx-line) {
4
- stroke: var(--wpds-color-stroke-surface-neutral, #dbdbdb);
4
+ stroke: var(--a8c-charts-color-grid, var(--wpds-color-stroke-surface-neutral, #dbdbdb));
5
5
  stroke-width: 1px;
6
6
  shape-rendering: crispEdges;
7
7
  }
@@ -1,5 +1,5 @@
1
1
  .svg-empty-state {
2
2
  text-align: center;
3
3
  font-size: var(--wpds-typography-font-size-md, 13px);
4
- color: var(--wpds-color-foreground-content-neutral-weak, #707070);
4
+ color: var(--a8c-charts-color-label-secondary, var(--wpds-color-foreground-content-neutral-weak, #707070));
5
5
  }