@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.
- package/CHANGELOG.md +19 -0
- package/TOKENS.md +83 -0
- package/dist/index.cjs +894 -139
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +58 -62
- package/dist/index.d.cts +75 -13
- package/dist/index.d.ts +75 -13
- package/dist/index.js +894 -139
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/charts/area-chart/area-chart.tsx +28 -8
- package/src/charts/area-chart/test/area-chart.test.tsx +102 -0
- package/src/charts/area-chart/types.ts +20 -6
- package/src/charts/bar-chart/bar-chart.tsx +33 -9
- package/src/charts/bar-chart/test/bar-chart.test.tsx +117 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +7 -7
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +18 -13
- package/src/charts/heatmap-chart/heatmap-chart.tsx +11 -7
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +3 -3
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +13 -7
- package/src/charts/leaderboard-chart/hooks/index.ts +1 -0
- package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +115 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +39 -12
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +67 -14
- package/src/charts/leaderboard-chart/test/leaderboard-chart-fit-rows.test.tsx +365 -0
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +73 -2
- package/src/charts/leaderboard-chart/types.ts +12 -0
- package/src/charts/line-chart/line-chart.module.scss +2 -2
- package/src/charts/line-chart/line-chart.tsx +40 -5
- package/src/charts/line-chart/test/line-chart.test.tsx +190 -1
- package/src/charts/line-chart/types.ts +14 -0
- package/src/charts/private/grid-control/grid-control.module.scss +1 -1
- package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
- package/src/charts/private/x-zoom/index.ts +1 -0
- package/src/charts/private/{test → x-zoom/test}/x-zoom.test.tsx +46 -3
- package/src/charts/private/x-zoom/x-zoom.module.scss +43 -0
- package/src/charts/private/{x-zoom.tsx → x-zoom/x-zoom.tsx} +52 -24
- package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +152 -0
- package/src/components/legend/hooks/use-chart-legend-items.ts +118 -16
- package/src/components/legend/private/base-legend.tsx +21 -9
- package/src/components/legend/test/legend-config-types.test.tsx +32 -0
- package/src/components/legend/test/legend.test.tsx +121 -1
- package/src/components/legend/types.ts +6 -0
- package/src/components/tooltip/base-tooltip.module.scss +3 -2
- package/src/components/trend-indicator/trend-indicator.module.scss +6 -5
- package/src/index.ts +1 -0
- package/src/providers/chart-context/themes.ts +33 -19
- package/src/types.ts +18 -2
- 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( '
|
|
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
|
-
{
|
|
448
|
-
|
|
449
|
-
|
|
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
|