@automattic/charts 1.11.0 → 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 +10 -2
- package/TOKENS.md +12 -1
- package/dist/index.cjs +751 -97
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +11 -28
- package/dist/index.d.cts +58 -6
- package/dist/index.d.ts +58 -6
- package/dist/index.js +751 -97
- 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/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/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/index.ts +1 -0
- package/src/types.ts +16 -0
- package/src/charts/private/x-zoom.module.scss +0 -50
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "Display charts within Automattic products.",
|
|
5
5
|
"homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -105,6 +105,7 @@
|
|
|
105
105
|
"@testing-library/user-event": "^14.4.3",
|
|
106
106
|
"@tsdown/css": "0.22.2",
|
|
107
107
|
"@types/jest": "^30.0.0",
|
|
108
|
+
"@types/node": "^24.12.0",
|
|
108
109
|
"@types/react": "18.3.28",
|
|
109
110
|
"@types/react-dom": "18.3.7",
|
|
110
111
|
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
@@ -70,7 +70,8 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
70
70
|
onPointerMove,
|
|
71
71
|
onPointerOut,
|
|
72
72
|
zoomable = false,
|
|
73
|
-
|
|
73
|
+
rescaleYOnVisibilityChange,
|
|
74
|
+
rescaleYOnLegendToggle,
|
|
74
75
|
children,
|
|
75
76
|
gridVisibility,
|
|
76
77
|
gap = 'md',
|
|
@@ -81,6 +82,9 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
81
82
|
const legendShape = legend.shape ?? 'rect';
|
|
82
83
|
const legendPosition = legend.position ?? 'bottom';
|
|
83
84
|
|
|
85
|
+
// New prop wins; fall back to the deprecated `rescaleYOnLegendToggle`; default to rescaling.
|
|
86
|
+
const rescaleYOnVisibility = rescaleYOnVisibilityChange ?? rescaleYOnLegendToggle ?? true;
|
|
87
|
+
|
|
84
88
|
const providerTheme = useGlobalChartsTheme();
|
|
85
89
|
const theme = useXYChartTheme( data );
|
|
86
90
|
const chartId = useChartId( providedChartId );
|
|
@@ -147,12 +151,12 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
147
151
|
// Computed from the full data set (ignoring legend visibility) so the y-axis stays
|
|
148
152
|
// fixed when series are toggled off - otherwise visx auto-fits to the remaining
|
|
149
153
|
// data and the chart's baseline appears to move. Opt-in via
|
|
150
|
-
// `
|
|
154
|
+
// `rescaleYOnVisibilityChange={ false }`. Skipped for non-default stack offsets,
|
|
151
155
|
// which reshape the y-extent (`expand` -> [0,1], `wiggle`/`silhouette` -> centred
|
|
152
156
|
// around zero); letting visx derive the domain is correct there.
|
|
153
157
|
const fixedYDomain = useMemo< [ number, number ] | undefined >( () => {
|
|
154
158
|
if (
|
|
155
|
-
|
|
159
|
+
rescaleYOnVisibility ||
|
|
156
160
|
! legendInteractive ||
|
|
157
161
|
! dataSorted.length ||
|
|
158
162
|
! dataSorted[ 0 ].data.length ||
|
|
@@ -195,7 +199,7 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
195
199
|
}
|
|
196
200
|
if ( max === -Infinity ) return undefined;
|
|
197
201
|
return [ Math.min( 0, min ), max ];
|
|
198
|
-
}, [ dataSorted, stacked, stackOffset, legendInteractive,
|
|
202
|
+
}, [ dataSorted, stacked, stackOffset, legendInteractive, rescaleYOnVisibility ] );
|
|
199
203
|
|
|
200
204
|
const chartOptions = useMemo( () => {
|
|
201
205
|
const formatter = options?.axis?.x?.tickFormat || getFormatter( dataSorted );
|
|
@@ -238,7 +242,14 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
238
242
|
const error = validateData( dataSorted );
|
|
239
243
|
const isDataValid = ! error;
|
|
240
244
|
|
|
241
|
-
const legendOptions = useMemo(
|
|
245
|
+
const legendOptions = useMemo(
|
|
246
|
+
() => ( {
|
|
247
|
+
withGlyph: false,
|
|
248
|
+
glyphSize: 0,
|
|
249
|
+
collapseGroups: legend.collapseGroups ?? false,
|
|
250
|
+
} ),
|
|
251
|
+
[ legend.collapseGroups ]
|
|
252
|
+
);
|
|
242
253
|
const legendItems = useChartLegendItems( dataSorted, legendOptions, legendShape );
|
|
243
254
|
|
|
244
255
|
const chartMetadata = useMemo(
|
|
@@ -405,9 +416,18 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
405
416
|
onPointerOut={ onPointerOut }
|
|
406
417
|
pointerEventsDataKey="nearest"
|
|
407
418
|
>
|
|
408
|
-
{
|
|
409
|
-
|
|
410
|
-
|
|
419
|
+
{ /* With every series hidden the value scale collapses, so the grid and axes
|
|
420
|
+
are dropped while the empty state stands in — otherwise they render
|
|
421
|
+
squished at the top. */ }
|
|
422
|
+
{ ! allSeriesHidden && gridVisibility !== 'none' && (
|
|
423
|
+
<Grid columns={ false } numTicks={ 4 } />
|
|
424
|
+
) }
|
|
425
|
+
{ ! allSeriesHidden && chartOptions.axis.x.display && (
|
|
426
|
+
<Axis { ...chartOptions.axis.x } />
|
|
427
|
+
) }
|
|
428
|
+
{ ! allSeriesHidden && chartOptions.axis.y.display && (
|
|
429
|
+
<Axis { ...chartOptions.axis.y } />
|
|
430
|
+
) }
|
|
411
431
|
|
|
412
432
|
{ allSeriesHidden ? (
|
|
413
433
|
<SvgEmptyState
|
|
@@ -333,6 +333,35 @@ describe( 'AreaChart', () => {
|
|
|
333
333
|
expect( afterToggleDomain ).toEqual( initialDomain );
|
|
334
334
|
} );
|
|
335
335
|
|
|
336
|
+
test( 'y-axis stays pinned when rescaleYOnVisibilityChange is false', async () => {
|
|
337
|
+
const user = userEvent.setup();
|
|
338
|
+
const ref = createRef< SingleChartRef >();
|
|
339
|
+
render(
|
|
340
|
+
<GlobalChartsProvider>
|
|
341
|
+
<AreaChartUnresponsive
|
|
342
|
+
{ ...defaultProps }
|
|
343
|
+
showLegend
|
|
344
|
+
chartId="test-interactive-domain-pin-new"
|
|
345
|
+
legend={ { interactive: true } }
|
|
346
|
+
rescaleYOnVisibilityChange={ false }
|
|
347
|
+
ref={ ref }
|
|
348
|
+
/>
|
|
349
|
+
</GlobalChartsProvider>
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const initialDomain = (
|
|
353
|
+
ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
|
|
354
|
+
)?.domain();
|
|
355
|
+
expect( initialDomain ).toBeDefined();
|
|
356
|
+
|
|
357
|
+
await user.click( screen.getByText( 'Series A' ) );
|
|
358
|
+
|
|
359
|
+
const afterToggleDomain = (
|
|
360
|
+
ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
|
|
361
|
+
)?.domain();
|
|
362
|
+
expect( afterToggleDomain ).toEqual( initialDomain );
|
|
363
|
+
} );
|
|
364
|
+
|
|
336
365
|
test( 'supports negative stacked values without clipping (with pinned Y)', () => {
|
|
337
366
|
// The mixed-sign full-extent pin only kicks in when the consumer
|
|
338
367
|
// opts into pinned-Y behavior; visx's natural domain derivation for
|
|
@@ -544,4 +573,77 @@ describe( 'AreaChart', () => {
|
|
|
544
573
|
'url(#chart-zoom-clip-zoomtest)'
|
|
545
574
|
);
|
|
546
575
|
} );
|
|
576
|
+
|
|
577
|
+
describe( 'Legend group collapsing', () => {
|
|
578
|
+
const groupedData = [
|
|
579
|
+
{
|
|
580
|
+
label: 'Views',
|
|
581
|
+
group: 'views',
|
|
582
|
+
data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
|
|
583
|
+
},
|
|
584
|
+
{
|
|
585
|
+
label: 'Views — previous',
|
|
586
|
+
group: 'views',
|
|
587
|
+
options: { type: 'comparison' as const },
|
|
588
|
+
data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
|
|
589
|
+
},
|
|
590
|
+
];
|
|
591
|
+
|
|
592
|
+
test( 'renders one legend item per series by default', () => {
|
|
593
|
+
renderWithProvider( { showLegend: true, data: groupedData } );
|
|
594
|
+
|
|
595
|
+
expect( screen.getByText( 'Views' ) ).toBeInTheDocument();
|
|
596
|
+
expect( screen.getByText( 'Views — previous' ) ).toBeInTheDocument();
|
|
597
|
+
} );
|
|
598
|
+
|
|
599
|
+
test( 'collapses a group to one item when legend.collapseGroups is set', () => {
|
|
600
|
+
renderWithProvider( {
|
|
601
|
+
showLegend: true,
|
|
602
|
+
legend: { collapseGroups: true },
|
|
603
|
+
data: groupedData,
|
|
604
|
+
} );
|
|
605
|
+
|
|
606
|
+
expect( screen.getByText( 'Views' ) ).toBeInTheDocument();
|
|
607
|
+
expect( screen.queryByText( 'Views — previous' ) ).not.toBeInTheDocument();
|
|
608
|
+
} );
|
|
609
|
+
} );
|
|
610
|
+
|
|
611
|
+
describe( 'All series hidden', () => {
|
|
612
|
+
it( 'drops the grid so it does not collapse when every series is hidden', async () => {
|
|
613
|
+
const user = userEvent.setup();
|
|
614
|
+
|
|
615
|
+
renderWithProvider( {
|
|
616
|
+
showLegend: true,
|
|
617
|
+
legend: { interactive: true },
|
|
618
|
+
chartId: 'area-hidden-axes',
|
|
619
|
+
data: [
|
|
620
|
+
{
|
|
621
|
+
label: 'Series A',
|
|
622
|
+
data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
label: 'Series B',
|
|
626
|
+
data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
|
|
627
|
+
},
|
|
628
|
+
],
|
|
629
|
+
} );
|
|
630
|
+
|
|
631
|
+
// The visx grid has no role/testid, so query its internal class from within the plot.
|
|
632
|
+
/* eslint-disable testing-library/no-node-access */
|
|
633
|
+
const plot = screen.getByRole( 'grid' );
|
|
634
|
+
expect( plot.querySelector( '.visx-rows' ) ).toBeInTheDocument();
|
|
635
|
+
|
|
636
|
+
const buttons = screen.getAllByRole( 'button' );
|
|
637
|
+
await user.click( buttons[ 0 ] );
|
|
638
|
+
await user.click( buttons[ 1 ] );
|
|
639
|
+
|
|
640
|
+
// With no visible data the value scale collapses, so the grid is removed rather than
|
|
641
|
+
// rendered squished at the top behind the empty state.
|
|
642
|
+
expect( plot.querySelector( '.visx-rows' ) ).not.toBeInTheDocument();
|
|
643
|
+
/* eslint-enable testing-library/no-node-access */
|
|
644
|
+
expect(
|
|
645
|
+
screen.getByText( /all series are hidden.*click legend items to show data/i )
|
|
646
|
+
).toBeInTheDocument();
|
|
647
|
+
} );
|
|
648
|
+
} );
|
|
547
649
|
} );
|
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
BaseChartProps,
|
|
3
|
+
DataPointDate,
|
|
4
|
+
SeriesData,
|
|
5
|
+
SeriesChartLegendConfig,
|
|
6
|
+
} from '../../types';
|
|
2
7
|
import type { RenderTooltipParams } from '../../visx/types';
|
|
3
8
|
import type { CurveType } from '../line-chart/types';
|
|
4
9
|
import type { ReactNode } from 'react';
|
|
5
10
|
|
|
6
11
|
export interface AreaChartProps extends BaseChartProps< SeriesData[] > {
|
|
12
|
+
/**
|
|
13
|
+
* Legend configuration. Supports `collapseGroups` on top of the shared options.
|
|
14
|
+
*/
|
|
15
|
+
legend?: SeriesChartLegendConfig;
|
|
7
16
|
/**
|
|
8
17
|
* Whether series should be stacked on top of each other.
|
|
9
18
|
* When false, series are rendered as overlapping filled areas.
|
|
@@ -54,13 +63,18 @@ export interface AreaChartProps extends BaseChartProps< SeriesData[] > {
|
|
|
54
63
|
*/
|
|
55
64
|
zoomable?: boolean;
|
|
56
65
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* the
|
|
61
|
-
* not move the chart's baseline.
|
|
66
|
+
* Whether the Y axis rescales to fit only the visible series when series are
|
|
67
|
+
* hidden or shown through the interactive legend.
|
|
68
|
+
* Defaults to `true`, matching LineChart. Set to `false` to pin the Y axis to
|
|
69
|
+
* the full data extent so hiding series does not move the chart's baseline.
|
|
62
70
|
* @default true
|
|
63
71
|
*/
|
|
72
|
+
rescaleYOnVisibilityChange?: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* @deprecated Use `rescaleYOnVisibilityChange`. The behaviour keys off series
|
|
75
|
+
* visibility changing, not specifically a legend toggle. Still honoured when
|
|
76
|
+
* `rescaleYOnVisibilityChange` is not set.
|
|
77
|
+
*/
|
|
64
78
|
rescaleYOnLegendToggle?: boolean;
|
|
65
79
|
children?: ReactNode;
|
|
66
80
|
}
|
|
@@ -37,12 +37,22 @@ import {
|
|
|
37
37
|
BASE_BAND_PADDING_INNER,
|
|
38
38
|
} from './private';
|
|
39
39
|
import type { ComparisonSeriesEntry } from './private';
|
|
40
|
-
import type {
|
|
40
|
+
import type {
|
|
41
|
+
BaseChartProps,
|
|
42
|
+
DataPointDate,
|
|
43
|
+
SeriesData,
|
|
44
|
+
SeriesChartLegendConfig,
|
|
45
|
+
Optional,
|
|
46
|
+
} from '../../types';
|
|
41
47
|
import type { RenderTooltipParams } from '../../visx/types';
|
|
42
48
|
import type { ResponsiveConfig } from '../private/with-responsive';
|
|
43
49
|
import type { FC, ReactNode, ComponentType } from 'react';
|
|
44
50
|
|
|
45
51
|
export interface BarChartProps extends BaseChartProps< SeriesData[] > {
|
|
52
|
+
/**
|
|
53
|
+
* Legend configuration. Supports `collapseGroups` on top of the shared options.
|
|
54
|
+
*/
|
|
55
|
+
legend?: SeriesChartLegendConfig;
|
|
46
56
|
renderTooltip?: ( params: RenderTooltipParams< DataPointDate > ) => ReactNode;
|
|
47
57
|
orientation?: 'horizontal' | 'vertical';
|
|
48
58
|
withPatterns?: boolean;
|
|
@@ -117,6 +127,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
117
127
|
gap = 'md',
|
|
118
128
|
} ) => {
|
|
119
129
|
const legendInteractive = legend.interactive ?? false;
|
|
130
|
+
const legendCollapseGroups = legend.collapseGroups ?? false;
|
|
120
131
|
const horizontal = orientation === 'horizontal';
|
|
121
132
|
const chartId = useChartId( providedChartId );
|
|
122
133
|
const theme = useXYChartTheme( data );
|
|
@@ -131,7 +142,11 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
131
142
|
} );
|
|
132
143
|
|
|
133
144
|
// Create legend items using the reusable hook
|
|
134
|
-
const
|
|
145
|
+
const legendOptions = useMemo(
|
|
146
|
+
() => ( { collapseGroups: legendCollapseGroups } ),
|
|
147
|
+
[ legendCollapseGroups ]
|
|
148
|
+
);
|
|
149
|
+
const legendItems = useChartLegendItems( dataSorted, legendOptions );
|
|
135
150
|
const chartOptions = useBarChartOptions( dataWithVisibleZeros, horizontal, options );
|
|
136
151
|
const defaultMargin = useChartMargin( height, chartOptions, dataSorted, theme, horizontal );
|
|
137
152
|
const chartRef = useRef< HTMLDivElement >( null );
|
|
@@ -544,11 +559,13 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
544
559
|
horizontal={ horizontal }
|
|
545
560
|
pointerEventsDataKey="nearest"
|
|
546
561
|
>
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
562
|
+
{ ! allSeriesHidden && (
|
|
563
|
+
<Grid
|
|
564
|
+
columns={ gridVisibility.includes( 'y' ) }
|
|
565
|
+
rows={ gridVisibility.includes( 'x' ) }
|
|
566
|
+
numTicks={ 4 }
|
|
567
|
+
/>
|
|
568
|
+
) }
|
|
552
569
|
|
|
553
570
|
{ withPatterns && (
|
|
554
571
|
<>
|
|
@@ -615,8 +632,15 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
615
632
|
) ) }
|
|
616
633
|
</BarGroup>
|
|
617
634
|
|
|
618
|
-
|
|
619
|
-
|
|
635
|
+
{ /* With every series hidden there is no data to build the value scale from, so
|
|
636
|
+
visx collapses the domain and the axes render squished at the top. Drop them
|
|
637
|
+
while the empty state stands in. */ }
|
|
638
|
+
{ ! allSeriesHidden && (
|
|
639
|
+
<>
|
|
640
|
+
<Axis { ...chartOptions.axis.x } />
|
|
641
|
+
<Axis { ...chartOptions.axis.y } />
|
|
642
|
+
</>
|
|
643
|
+
) }
|
|
620
644
|
|
|
621
645
|
{ withTooltips && (
|
|
622
646
|
<AccessibleTooltip
|
|
@@ -1194,5 +1194,122 @@ describe( 'BarChart', () => {
|
|
|
1194
1194
|
screen.getByText( /all series are hidden.*click legend items to show data/i )
|
|
1195
1195
|
).toBeInTheDocument();
|
|
1196
1196
|
} );
|
|
1197
|
+
|
|
1198
|
+
it( 'drops the value-axis ticks when all series are hidden so the axes do not collapse', async () => {
|
|
1199
|
+
const user = userEvent.setup();
|
|
1200
|
+
|
|
1201
|
+
renderWithTheme( {
|
|
1202
|
+
showLegend: true,
|
|
1203
|
+
gridVisibility: 'both',
|
|
1204
|
+
legend: { interactive: true },
|
|
1205
|
+
chartId: 'test-hidden-axes-bar-chart',
|
|
1206
|
+
data: [
|
|
1207
|
+
{
|
|
1208
|
+
label: 'Series A',
|
|
1209
|
+
data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
|
|
1210
|
+
options: {},
|
|
1211
|
+
},
|
|
1212
|
+
{
|
|
1213
|
+
label: 'Series B',
|
|
1214
|
+
data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
|
|
1215
|
+
options: {},
|
|
1216
|
+
},
|
|
1217
|
+
],
|
|
1218
|
+
} );
|
|
1219
|
+
|
|
1220
|
+
// The value axis renders numeric tick labels while there is data to scale against.
|
|
1221
|
+
// (Series labels carry no digits and the empty-state message is text-only, so a bare
|
|
1222
|
+
// number can only be an axis tick.)
|
|
1223
|
+
const numericTick = /^[\d,]+$/;
|
|
1224
|
+
expect( screen.getAllByText( numericTick ).length ).toBeGreaterThan( 0 );
|
|
1225
|
+
|
|
1226
|
+
const legendItems = screen.getAllByRole( 'button' );
|
|
1227
|
+
await user.click( legendItems[ 0 ] );
|
|
1228
|
+
await user.click( legendItems[ 1 ] );
|
|
1229
|
+
|
|
1230
|
+
// With no visible data the value scale would collapse, so the axes are removed rather
|
|
1231
|
+
// than rendered squished at the top — no tick labels remain.
|
|
1232
|
+
expect( screen.queryAllByText( numericTick ) ).toHaveLength( 0 );
|
|
1233
|
+
expect(
|
|
1234
|
+
screen.getByText( /all series are hidden.*click legend items to show data/i )
|
|
1235
|
+
).toBeInTheDocument();
|
|
1236
|
+
} );
|
|
1237
|
+
} );
|
|
1238
|
+
|
|
1239
|
+
describe( 'Legend group collapsing', () => {
|
|
1240
|
+
const comparisonPair = [
|
|
1241
|
+
{
|
|
1242
|
+
label: 'Views',
|
|
1243
|
+
group: 'views',
|
|
1244
|
+
data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
|
|
1245
|
+
},
|
|
1246
|
+
{
|
|
1247
|
+
label: 'Views — previous',
|
|
1248
|
+
group: 'views',
|
|
1249
|
+
options: { type: 'comparison' as const },
|
|
1250
|
+
data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
|
|
1251
|
+
},
|
|
1252
|
+
];
|
|
1253
|
+
|
|
1254
|
+
it( 'renders one legend item per series by default', () => {
|
|
1255
|
+
renderWithTheme( {
|
|
1256
|
+
showLegend: true,
|
|
1257
|
+
chartId: 'bar-legend-groups-default',
|
|
1258
|
+
data: comparisonPair,
|
|
1259
|
+
} );
|
|
1260
|
+
|
|
1261
|
+
expect( screen.getByText( 'Views' ) ).toBeInTheDocument();
|
|
1262
|
+
expect( screen.getByText( 'Views — previous' ) ).toBeInTheDocument();
|
|
1263
|
+
} );
|
|
1264
|
+
|
|
1265
|
+
it( 'collapses a group to one item when legend.collapseGroups is set', () => {
|
|
1266
|
+
renderWithTheme( {
|
|
1267
|
+
showLegend: true,
|
|
1268
|
+
legend: { collapseGroups: true },
|
|
1269
|
+
chartId: 'bar-legend-groups-collapsed',
|
|
1270
|
+
data: comparisonPair,
|
|
1271
|
+
} );
|
|
1272
|
+
|
|
1273
|
+
expect( screen.getByText( 'Views' ) ).toBeInTheDocument();
|
|
1274
|
+
expect( screen.queryByText( 'Views — previous' ) ).not.toBeInTheDocument();
|
|
1275
|
+
} );
|
|
1276
|
+
|
|
1277
|
+
it( 'toggles only the clicked series when interactive without collapseGroups', async () => {
|
|
1278
|
+
const user = userEvent.setup();
|
|
1279
|
+
|
|
1280
|
+
renderWithTheme( {
|
|
1281
|
+
showLegend: true,
|
|
1282
|
+
legend: { interactive: true },
|
|
1283
|
+
chartId: 'bar-legend-groups-interactive',
|
|
1284
|
+
data: comparisonPair,
|
|
1285
|
+
} );
|
|
1286
|
+
|
|
1287
|
+
const buttons = screen.getAllByRole( 'button' );
|
|
1288
|
+
await user.click( buttons[ 0 ] );
|
|
1289
|
+
|
|
1290
|
+
expect( buttons[ 0 ] ).toHaveAttribute( 'aria-pressed', 'false' );
|
|
1291
|
+
expect( buttons[ 1 ] ).toHaveAttribute( 'aria-pressed', 'true' );
|
|
1292
|
+
} );
|
|
1293
|
+
|
|
1294
|
+
it( 'toggles the whole group when interactive with collapseGroups', async () => {
|
|
1295
|
+
const user = userEvent.setup();
|
|
1296
|
+
|
|
1297
|
+
renderWithTheme( {
|
|
1298
|
+
showLegend: true,
|
|
1299
|
+
legend: { interactive: true, collapseGroups: true },
|
|
1300
|
+
chartId: 'bar-legend-groups-interactive-collapsed',
|
|
1301
|
+
data: comparisonPair,
|
|
1302
|
+
} );
|
|
1303
|
+
|
|
1304
|
+
const buttons = screen.getAllByRole( 'button' );
|
|
1305
|
+
expect( buttons ).toHaveLength( 1 );
|
|
1306
|
+
|
|
1307
|
+
await user.click( buttons[ 0 ] );
|
|
1308
|
+
|
|
1309
|
+
expect( buttons[ 0 ] ).toHaveAttribute( 'aria-pressed', 'false' );
|
|
1310
|
+
expect(
|
|
1311
|
+
screen.getByText( /all series are hidden.*click legend items to show data/i )
|
|
1312
|
+
).toBeInTheDocument();
|
|
1313
|
+
} );
|
|
1197
1314
|
} );
|
|
1198
1315
|
} );
|
|
@@ -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
|