@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
@@ -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
 
@@ -179,6 +179,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
179
179
  onPointerMove = undefined,
180
180
  onPointerOut = undefined,
181
181
  zoomable = false,
182
+ rescaleYOnVisibilityChange = true,
182
183
  children,
183
184
  gridVisibility,
184
185
  gap = 'md',
@@ -186,6 +187,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
186
187
  ref
187
188
  ) => {
188
189
  const legendInteractive = legend.interactive ?? false;
190
+ const legendCollapseGroups = legend.collapseGroups ?? false;
189
191
  const legendShape = legend.shape ?? 'line';
190
192
  const legendPosition = legend.position ?? 'bottom';
191
193
 
@@ -252,6 +254,28 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
252
254
  return seriesWithVisibility.every( ( { isVisible } ) => ! isVisible );
253
255
  }, [ seriesWithVisibility ] );
254
256
 
257
+ // When the interactive legend can hide series and rescaling is opted out, pin the value axis
258
+ // to the full data range so it stays put as series are toggled instead of visx rescaling the
259
+ // domain to whatever is currently visible and making the axis jump. Default is to rescale,
260
+ // matching the pre-existing behaviour and AreaChart's `rescaleYOnVisibilityChange`.
261
+ const stableYDomain = useMemo< [ number, number ] | undefined >( () => {
262
+ if ( ! legendInteractive || rescaleYOnVisibilityChange ) {
263
+ return undefined;
264
+ }
265
+ let min = Infinity;
266
+ let max = -Infinity;
267
+ for ( const series of dataSorted ) {
268
+ for ( const point of series.data ?? [] ) {
269
+ const value = point?.value;
270
+ if ( typeof value === 'number' && Number.isFinite( value ) ) {
271
+ min = Math.min( min, value );
272
+ max = Math.max( max, value );
273
+ }
274
+ }
275
+ }
276
+ return min < max ? [ min, max ] : undefined;
277
+ }, [ legendInteractive, rescaleYOnVisibilityChange, dataSorted ] );
278
+
255
279
  // Use the keyboard navigation hook
256
280
  const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
257
281
  selectedIndex,
@@ -291,10 +315,11 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
291
315
  type: 'linear' as const,
292
316
  nice: true,
293
317
  zero: false,
318
+ ...( stableYDomain ? { domain: stableYDomain } : {} ),
294
319
  ...options?.yScale,
295
320
  },
296
321
  };
297
- }, [ options, dataSorted, width, zoom.domain ] );
322
+ }, [ options, dataSorted, width, zoom.domain, stableYDomain ] );
298
323
 
299
324
  const tooltipRenderGlyph = useMemo( () => {
300
325
  return ( props: GlyphProps< DataPointDate > ) => {
@@ -328,9 +353,10 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
328
353
  () => ( {
329
354
  withGlyph: withLegendGlyph,
330
355
  glyphSize: Math.max( 0, toNumber( glyphStyle?.radius ) ?? 4 ),
356
+ collapseGroups: legendCollapseGroups,
331
357
  renderGlyph,
332
358
  } ),
333
- [ withLegendGlyph, glyphStyle?.radius, renderGlyph ]
359
+ [ withLegendGlyph, glyphStyle?.radius, legendCollapseGroups, renderGlyph ]
334
360
  );
335
361
 
336
362
  // Create legend items using the reusable hook
@@ -444,9 +470,18 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
444
470
  onPointerOut={ onPointerOut }
445
471
  pointerEventsDataKey="nearest"
446
472
  >
447
- { gridVisibility !== 'none' && <Grid columns={ false } numTicks={ 4 } /> }
448
- { chartOptions.axis.x.display && <Axis { ...chartOptions.axis.x } /> }
449
- { chartOptions.axis.y.display && <Axis { ...chartOptions.axis.y } /> }
473
+ { /* With every series hidden there is no data to scale against, so the grid and
474
+ axes are dropped while the empty state stands in — otherwise they render
475
+ squished at the top. */ }
476
+ { ! allSeriesHidden && gridVisibility !== 'none' && (
477
+ <Grid columns={ false } numTicks={ 4 } />
478
+ ) }
479
+ { ! allSeriesHidden && chartOptions.axis.x.display && (
480
+ <Axis { ...chartOptions.axis.x } />
481
+ ) }
482
+ { ! allSeriesHidden && chartOptions.axis.y.display && (
483
+ <Axis { ...chartOptions.axis.y } />
484
+ ) }
450
485
 
451
486
  { allSeriesHidden ? (
452
487
  <SvgEmptyState