@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
|
@@ -285,7 +285,7 @@ describe( 'HeatmapChart', () => {
|
|
|
285
285
|
renderChart();
|
|
286
286
|
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
287
287
|
// Non-compact sets no inline gap — the SCSS falls back to the WPDS token.
|
|
288
|
-
expect( grid.style.getPropertyValue( '--heatmap-cell-gap' ) ).toBe( '' );
|
|
288
|
+
expect( grid.style.getPropertyValue( '--a8c-charts-dimension-heatmap-cell-gap' ) ).toBe( '' );
|
|
289
289
|
} );
|
|
290
290
|
|
|
291
291
|
test( 'applies the compact gap inline from the theme compactCellGap', () => {
|
|
@@ -295,7 +295,9 @@ describe( 'HeatmapChart', () => {
|
|
|
295
295
|
</GlobalChartsProvider>
|
|
296
296
|
);
|
|
297
297
|
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
298
|
-
expect( grid.style.getPropertyValue( '--heatmap-cell-gap' ) ).toBe(
|
|
298
|
+
expect( grid.style.getPropertyValue( '--a8c-charts-dimension-heatmap-cell-gap' ) ).toBe(
|
|
299
|
+
'3px'
|
|
300
|
+
);
|
|
299
301
|
} );
|
|
300
302
|
|
|
301
303
|
test( 'sizes compact cells to the theme compactCellSize', () => {
|
|
@@ -305,9 +307,13 @@ describe( 'HeatmapChart', () => {
|
|
|
305
307
|
</GlobalChartsProvider>
|
|
306
308
|
);
|
|
307
309
|
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
308
|
-
expect( grid.style.getPropertyValue( '--heatmap-cell-size' ) ).toBe(
|
|
310
|
+
expect( grid.style.getPropertyValue( '--a8c-charts-dimension-heatmap-cell-size' ) ).toBe(
|
|
311
|
+
'20px'
|
|
312
|
+
);
|
|
309
313
|
// Compact track template is built from the fixed cell size.
|
|
310
|
-
expect( grid.style.gridTemplateColumns ).toContain(
|
|
314
|
+
expect( grid.style.gridTemplateColumns ).toContain(
|
|
315
|
+
'var(--a8c-charts-dimension-heatmap-cell-size)'
|
|
316
|
+
);
|
|
311
317
|
} );
|
|
312
318
|
|
|
313
319
|
test( 'caps cell width without changing the normal vertical layout', () => {
|
|
@@ -343,7 +349,7 @@ describe( 'HeatmapChart', () => {
|
|
|
343
349
|
test( 'applies the primaryColor prop as the cell-scale color', () => {
|
|
344
350
|
renderChart( { primaryColor: '#abcdef' } );
|
|
345
351
|
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
346
|
-
expect( grid.style.getPropertyValue( '--heatmap-primary' ) ).toBe( '#abcdef' );
|
|
352
|
+
expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe( '#abcdef' );
|
|
347
353
|
} );
|
|
348
354
|
|
|
349
355
|
test( 'resolves primaryColor from the chart theme', () => {
|
|
@@ -353,7 +359,7 @@ describe( 'HeatmapChart', () => {
|
|
|
353
359
|
</GlobalChartsProvider>
|
|
354
360
|
);
|
|
355
361
|
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
356
|
-
expect( grid.style.getPropertyValue( '--heatmap-primary' ) ).toBe( '#0a0b0c' );
|
|
362
|
+
expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe( '#0a0b0c' );
|
|
357
363
|
} );
|
|
358
364
|
|
|
359
365
|
test( 'falls back to the palette colors[0] when no prop or theme primaryColor is set', () => {
|
|
@@ -363,7 +369,7 @@ describe( 'HeatmapChart', () => {
|
|
|
363
369
|
</GlobalChartsProvider>
|
|
364
370
|
);
|
|
365
371
|
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
366
|
-
expect( grid.style.getPropertyValue( '--heatmap-primary' ) ).toBe( '#0a0b0c' );
|
|
372
|
+
expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe( '#0a0b0c' );
|
|
367
373
|
} );
|
|
368
374
|
|
|
369
375
|
test( 'the unresponsive export pins explicit width and height', () => {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fractional layout rounding can put a row's bottom a hair past the container's
|
|
5
|
+
* without any visible clipping. Allow that much slack before hiding a row.
|
|
6
|
+
*
|
|
7
|
+
* Exported so tests and stories assert against the real tolerance rather than
|
|
8
|
+
* restating it.
|
|
9
|
+
*/
|
|
10
|
+
export const SUBPIXEL_TOLERANCE = 0.5;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Counts how many leading rows fit inside the content container.
|
|
14
|
+
*
|
|
15
|
+
* Rows are read from the DOM rather than derived from constants: row height
|
|
16
|
+
* depends on the theme's row gap, label wrapping, and whatever a caller renders
|
|
17
|
+
* as a label, so any restated number would drift.
|
|
18
|
+
*
|
|
19
|
+
* Every row is a direct grid child marked with `data-row-index`; interactive rows
|
|
20
|
+
* use a button wrapper and non-interactive rows use a div wrapper.
|
|
21
|
+
*
|
|
22
|
+
* @param enabled - Whether to measure at all. When false every row fits.
|
|
23
|
+
* @param rowCount - Total number of rows rendered.
|
|
24
|
+
* @param data - Rendered row data, whose geometry may change without changing the count.
|
|
25
|
+
* @return Ref for the content container and the number of leading rows that fit.
|
|
26
|
+
*/
|
|
27
|
+
export function useFittedRowCount( enabled: boolean, rowCount: number, data: unknown ) {
|
|
28
|
+
const contentRef = useRef< HTMLDivElement | null >( null );
|
|
29
|
+
const [ fittedCount, setFittedCount ] = useState( rowCount );
|
|
30
|
+
const [ isMeasurable, setIsMeasurable ] = useState( true );
|
|
31
|
+
|
|
32
|
+
const measure = useCallback( () => {
|
|
33
|
+
const content = contentRef.current;
|
|
34
|
+
if ( ! content ) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Switching from the default scrollable mode can leave the content at a
|
|
39
|
+
// non-zero scroll offset, which would shift every measurement below.
|
|
40
|
+
content.scrollTop = 0;
|
|
41
|
+
|
|
42
|
+
const rows = content.querySelectorAll< HTMLElement >(
|
|
43
|
+
':scope > [data-leaderboard-grid] > [data-row-index]'
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const rowBottoms: number[] = [];
|
|
47
|
+
rows.forEach( row => {
|
|
48
|
+
const index = Number( row.getAttribute( 'data-row-index' ) );
|
|
49
|
+
// An unparseable index would write a non-numeric key, leaving a hole
|
|
50
|
+
// that silently truncates the scan below at the preceding row.
|
|
51
|
+
if ( ! Number.isInteger( index ) || index < 0 || index >= rowCount ) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const { bottom } = row.getBoundingClientRect();
|
|
55
|
+
rowBottoms[ index ] = Math.max( rowBottoms[ index ] ?? -Infinity, bottom );
|
|
56
|
+
} );
|
|
57
|
+
|
|
58
|
+
// Fail open. If the rows cannot be measured at all — a changed DOM shape,
|
|
59
|
+
// a detached container — falling back to the scrollable default keeps the
|
|
60
|
+
// data reachable, where hiding every row reads as a broken tile.
|
|
61
|
+
if ( rowBottoms.length === 0 ) {
|
|
62
|
+
setIsMeasurable( false );
|
|
63
|
+
setFittedCount( rowCount );
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
setIsMeasurable( true );
|
|
67
|
+
|
|
68
|
+
const contentBottom = content.getBoundingClientRect().bottom + SUBPIXEL_TOLERANCE;
|
|
69
|
+
|
|
70
|
+
let fits = 0;
|
|
71
|
+
while ( fits < rowBottoms.length && rowBottoms[ fits ] <= contentBottom ) {
|
|
72
|
+
fits++;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Bail before setState: setState inside a ResizeObserver callback is the
|
|
76
|
+
// usual way this grows a render loop.
|
|
77
|
+
setFittedCount( current => ( current === fits ? current : fits ) );
|
|
78
|
+
}, [ rowCount ] );
|
|
79
|
+
|
|
80
|
+
// Measure after row data changes so geometry updates are caught even when the
|
|
81
|
+
// row count and the grid's overall size stay the same (for example, one label
|
|
82
|
+
// wraps while another unwraps after a same-length data update).
|
|
83
|
+
useLayoutEffect( () => {
|
|
84
|
+
if ( ! enabled ) {
|
|
85
|
+
setFittedCount( rowCount );
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
measure();
|
|
90
|
+
}, [ enabled, rowCount, data, measure ] );
|
|
91
|
+
|
|
92
|
+
useLayoutEffect( () => {
|
|
93
|
+
if ( ! enabled ) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const content = contentRef.current;
|
|
98
|
+
if ( ! content ) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const observer = new ResizeObserver( measure );
|
|
103
|
+
// The container catches tile resizing; the grid catches font, image,
|
|
104
|
+
// theme, or label changes that alter row geometry on an unchanged tile.
|
|
105
|
+
observer.observe( content );
|
|
106
|
+
const grid = content.querySelector< HTMLElement >( ':scope > [data-leaderboard-grid]' );
|
|
107
|
+
if ( grid ) {
|
|
108
|
+
observer.observe( grid );
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return () => observer.disconnect();
|
|
112
|
+
}, [ enabled, rowCount, measure ] );
|
|
113
|
+
|
|
114
|
+
return { contentRef, fittedCount: enabled ? fittedCount : rowCount, isMeasurable };
|
|
115
|
+
}
|
|
@@ -16,6 +16,15 @@
|
|
|
16
16
|
flex: 1;
|
|
17
17
|
min-height: 0;
|
|
18
18
|
overflow: auto;
|
|
19
|
+
|
|
20
|
+
// With fitRows the chart hides the rows that would not fit, so there is
|
|
21
|
+
// nothing left to scroll to — a scrollbar here would only ever reveal the
|
|
22
|
+
// hidden rows' reserved space.
|
|
23
|
+
&--fit {
|
|
24
|
+
overflow: hidden;
|
|
25
|
+
// Anchors .fitEmptyState, which is laid over the hidden rows.
|
|
26
|
+
position: relative;
|
|
27
|
+
}
|
|
19
28
|
}
|
|
20
29
|
}
|
|
21
30
|
|
|
@@ -51,7 +60,7 @@
|
|
|
51
60
|
// row's fixed-pixel hover inset from collapsing a narrow bar to nothing.
|
|
52
61
|
min-width: var(--wpds-dimension-size-5xs, 4px);
|
|
53
62
|
|
|
54
|
-
border-radius: var(--a8c--charts--leaderboard--bar--border-radius, 9999px);
|
|
63
|
+
border-radius: var(--a8c-charts-border-radius-leaderboard-bar, var(--a8c--charts--leaderboard--bar--border-radius, 9999px));
|
|
55
64
|
z-index: -1; /* places it behind the label */
|
|
56
65
|
|
|
57
66
|
&--animated {
|
|
@@ -95,14 +104,32 @@
|
|
|
95
104
|
.emptyState {
|
|
96
105
|
padding: var(--wpds-dimension-padding-3xl, 32px) var(--wpds-dimension-padding-lg, 16px);
|
|
97
106
|
text-align: center;
|
|
98
|
-
color: var(--wpds-color-foreground-content-neutral-weak, #707070);
|
|
107
|
+
color: var(--a8c-charts-color-label-secondary, var(--wpds-color-foreground-content-neutral-weak, #707070));
|
|
99
108
|
font-size: var(--wpds-typography-font-size-md, 13px);
|
|
100
109
|
font-style: italic;
|
|
101
110
|
}
|
|
102
111
|
|
|
103
|
-
|
|
104
|
-
|
|
112
|
+
// Shown when not even one row fits. The rows stay in the layout underneath so
|
|
113
|
+
// they remain measurable, so this is taken out of flow to sit over them.
|
|
114
|
+
.fitEmptyState {
|
|
115
|
+
position: absolute;
|
|
116
|
+
inset: 0;
|
|
117
|
+
display: flex;
|
|
118
|
+
align-items: center;
|
|
119
|
+
justify-content: center;
|
|
120
|
+
// The shared empty state reserves generous vertical whitespace. This overlay
|
|
121
|
+
// is specifically for compact containers, where the text may fit even though
|
|
122
|
+
// a complete data row does not.
|
|
123
|
+
padding-block: 0;
|
|
124
|
+
}
|
|
105
125
|
|
|
126
|
+
// Focus-ring width shared so a standard row's reserved padding and an
|
|
127
|
+
// interactive row's outline (and its inset offset) stay equal. A Sass var,
|
|
128
|
+
// not a custom property: a compile-time constant scoped to this module, so it
|
|
129
|
+
// needs no runtime cascade and never lands on the DOM as an unprefixed var.
|
|
130
|
+
$focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-focus, 2px));
|
|
131
|
+
|
|
132
|
+
.row {
|
|
106
133
|
display: grid;
|
|
107
134
|
grid-column: 1 / -1;
|
|
108
135
|
grid-template-columns: subgrid;
|
|
@@ -110,7 +137,7 @@
|
|
|
110
137
|
// Every row reserves the same space for the interactive focus ring. Keeping
|
|
111
138
|
// this on the shared wrapper ensures that adding or removing an action never
|
|
112
139
|
// changes the row height or subgrid column edges.
|
|
113
|
-
padding:
|
|
140
|
+
padding: $focus-ring-width;
|
|
114
141
|
}
|
|
115
142
|
|
|
116
143
|
.interactiveRow {
|
|
@@ -153,14 +180,14 @@
|
|
|
153
180
|
|
|
154
181
|
// Fixed reserve for the chevron — the value slides by it in full, and the
|
|
155
182
|
// bar width inset (see leaderboard-chart.tsx) scales it by the bar's share.
|
|
156
|
-
--a8c
|
|
183
|
+
--a8c-charts-dimension-leaderboard-bar-hover-inset: var(--wpds-dimension-gap-2xl, 32px);
|
|
157
184
|
|
|
158
185
|
.bar {
|
|
159
186
|
opacity: 0.5;
|
|
160
187
|
}
|
|
161
188
|
|
|
162
189
|
.valueContainer {
|
|
163
|
-
transform: translateX(calc(var(--a8c
|
|
190
|
+
transform: translateX(calc(var(--a8c-charts-dimension-leaderboard-bar-hover-inset) * -1));
|
|
164
191
|
}
|
|
165
192
|
|
|
166
193
|
.chevron {
|
|
@@ -171,18 +198,18 @@
|
|
|
171
198
|
// In RTL the value slides toward the inline start the other way (rightward).
|
|
172
199
|
[dir="rtl"] &:hover .valueContainer,
|
|
173
200
|
[dir="rtl"] &:focus-visible .valueContainer {
|
|
174
|
-
transform: translateX(var(--a8c
|
|
201
|
+
transform: translateX(var(--a8c-charts-dimension-leaderboard-bar-hover-inset));
|
|
175
202
|
}
|
|
176
203
|
|
|
177
204
|
&:focus-visible {
|
|
178
205
|
border-radius: var(--wpds-border-radius-sm, 2px);
|
|
179
206
|
outline:
|
|
180
|
-
|
|
181
|
-
var(--wpds-color-stroke-focus, var(--wp-admin-theme-color, #3858e9));
|
|
207
|
+
$focus-ring-width solid
|
|
208
|
+
var(--a8c-charts-color-focus, var(--wpds-color-stroke-focus, var(--wp-admin-theme-color, #3858e9)));
|
|
182
209
|
// Inset by its own width so the outer edge hugs the row boundary: the
|
|
183
210
|
// outline then sits in the shared row padding, clear of the bar, and stays
|
|
184
211
|
// inside the overflow:auto container that would clip an outset ring.
|
|
185
|
-
outline-offset: calc(-1 *
|
|
212
|
+
outline-offset: calc(-1 * #{$focus-ring-width});
|
|
186
213
|
}
|
|
187
214
|
}
|
|
188
215
|
|
|
@@ -193,7 +220,7 @@
|
|
|
193
220
|
bottom: 0;
|
|
194
221
|
margin-block: auto;
|
|
195
222
|
opacity: 0;
|
|
196
|
-
color: var(--wpds-color-foreground-content-neutral-weak, #707070);
|
|
223
|
+
color: var(--a8c-charts-color-label-secondary, var(--wpds-color-foreground-content-neutral-weak, #707070));
|
|
197
224
|
transition:
|
|
198
225
|
opacity var(--wpds-motion-duration-sm, 100ms)
|
|
199
226
|
var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
|
|
@@ -20,7 +20,7 @@ import { useChartChildren } from '../private/chart-composition';
|
|
|
20
20
|
import { ChartLayout } from '../private/chart-layout';
|
|
21
21
|
import { SingleChartContext } from '../private/single-chart-context';
|
|
22
22
|
import { withResponsive } from '../private/with-responsive';
|
|
23
|
-
import { useLeaderboardLegendItems } from './hooks';
|
|
23
|
+
import { useFittedRowCount, useLeaderboardLegendItems } from './hooks';
|
|
24
24
|
import styles from './leaderboard-chart.module.scss';
|
|
25
25
|
import type { LeaderboardChartProps } from './types';
|
|
26
26
|
import type { LeaderboardEntry } from '../../types';
|
|
@@ -62,15 +62,22 @@ const defaultDeltaFormatter = ( value: number ): string => {
|
|
|
62
62
|
* @return A CSS width value.
|
|
63
63
|
*/
|
|
64
64
|
const getBarWidth = ( share: number ): string =>
|
|
65
|
-
`calc(${ share }% - var(--a8c
|
|
65
|
+
`calc(${ share }% - var(--a8c-charts-dimension-leaderboard-bar-hover-inset, 0px) * ${ share } / 100)`;
|
|
66
66
|
|
|
67
|
-
const
|
|
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
|
-
} =>
|
|
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 &&
|
|
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
|
|
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
|
|
348
|
-
|
|
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
|
|
351
|
-
const
|
|
352
|
-
const
|
|
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"
|
|
435
|
+
<span aria-hidden="true">—</span>
|
|
392
436
|
<VisuallyHidden as="span">
|
|
393
|
-
{
|
|
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
|
|
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
|
);
|