@automattic/charts 1.10.3 → 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 +11 -0
  2. package/TOKENS.md +72 -0
  3. package/dist/index.cjs +143 -42
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +54 -41
  6. package/dist/index.d.cts +17 -7
  7. package/dist/index.d.ts +17 -7
  8. package/dist/index.js +143 -42
  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 +39 -12
  19. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +67 -14
  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
@@ -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
  );
@@ -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
  }