@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
|
@@ -25,6 +25,11 @@ export interface ChartLegendOptions {
|
|
|
25
25
|
showValues?: boolean;
|
|
26
26
|
legendValueDisplay?: LegendValueDisplay;
|
|
27
27
|
legendShape?: LegendShape< SeriesData[], number >;
|
|
28
|
+
/**
|
|
29
|
+
* Collapse series that share a `group` into a single legend item, labelled by the group's
|
|
30
|
+
* primary series. Off by default, so every series gets its own item.
|
|
31
|
+
*/
|
|
32
|
+
collapseGroups?: boolean;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
/**
|
|
@@ -95,13 +100,90 @@ function applyGlyphToLegendItem(
|
|
|
95
100
|
return baseItem;
|
|
96
101
|
}
|
|
97
102
|
|
|
103
|
+
type SeriesGroupMember = { series: SeriesData; index: number };
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Buckets series by their `group`, preserving first-appearance order. Series with no group — or a
|
|
107
|
+
* group value unique to them — end up in a bucket of their own. Whether a multi-series bucket then
|
|
108
|
+
* collapses to a single legend item is decided by the caller: with `collapseGroups` on, every
|
|
109
|
+
* multi-member bucket collapses, and the comparison pattern only decides which member represents it.
|
|
110
|
+
* @param seriesData - The series data to group
|
|
111
|
+
* @return Ordered groups, each holding its member series with their original indices
|
|
112
|
+
*/
|
|
113
|
+
function groupSeriesForLegend( seriesData: SeriesData[] ): SeriesGroupMember[][] {
|
|
114
|
+
const groups: SeriesGroupMember[][] = [];
|
|
115
|
+
const groupIndexByKey = new Map< string, number >();
|
|
116
|
+
|
|
117
|
+
seriesData.forEach( ( series, index ) => {
|
|
118
|
+
const member: SeriesGroupMember = { series, index };
|
|
119
|
+
const key = series.group;
|
|
120
|
+
|
|
121
|
+
if ( key === undefined ) {
|
|
122
|
+
groups.push( [ member ] );
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const existing = groupIndexByKey.get( key );
|
|
127
|
+
if ( existing === undefined ) {
|
|
128
|
+
groupIndexByKey.set( key, groups.length );
|
|
129
|
+
groups.push( [ member ] );
|
|
130
|
+
} else {
|
|
131
|
+
groups[ existing ].push( member );
|
|
132
|
+
}
|
|
133
|
+
} );
|
|
134
|
+
|
|
135
|
+
return groups;
|
|
136
|
+
}
|
|
137
|
+
|
|
98
138
|
/**
|
|
99
|
-
*
|
|
139
|
+
* Builds a single legend item from a representative series, tagging it with the series it controls
|
|
140
|
+
* @param member - The series (with its original index) that provides the label/colour
|
|
141
|
+
* @param seriesLabels - Every series label this item toggles (grouped) or just its own
|
|
142
|
+
* @param getElementStyles - Function to get element styles
|
|
143
|
+
* @param showValues - Whether to show values in legend
|
|
144
|
+
* @param withGlyph - Whether to include glyph rendering
|
|
145
|
+
* @param glyphSize - Size of the glyph
|
|
146
|
+
* @param renderGlyph - Component to render the glyph
|
|
147
|
+
* @param legendShape - The shape type for legend items (string literal or React component)
|
|
148
|
+
* @return The processed legend item
|
|
149
|
+
*/
|
|
150
|
+
function buildSeriesLegendItem(
|
|
151
|
+
member: SeriesGroupMember,
|
|
152
|
+
seriesLabels: string[],
|
|
153
|
+
getElementStyles: ( params: GetElementStylesParams ) => ElementStyles,
|
|
154
|
+
showValues: boolean,
|
|
155
|
+
withGlyph: boolean,
|
|
156
|
+
glyphSize: number,
|
|
157
|
+
renderGlyph?: < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode,
|
|
158
|
+
legendShape?: LegendShape< SeriesData[], number >
|
|
159
|
+
): BaseLegendItem {
|
|
160
|
+
const { color, glyph, shapeStyles } = getElementStyles( {
|
|
161
|
+
data: member.series,
|
|
162
|
+
index: member.index,
|
|
163
|
+
legendShape,
|
|
164
|
+
} );
|
|
165
|
+
|
|
166
|
+
const baseItem: BaseLegendItem = {
|
|
167
|
+
label: member.series.label,
|
|
168
|
+
value: showValues ? member.series.data?.length?.toString() || '0' : '',
|
|
169
|
+
color,
|
|
170
|
+
shapeStyle: shapeStyles,
|
|
171
|
+
seriesLabels,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return applyGlyphToLegendItem( baseItem, withGlyph, glyph, renderGlyph, glyphSize );
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Processes SeriesData into legend items. Every series keeps its own legend entry unless
|
|
179
|
+
* `collapseGroups` is set, in which case series sharing a `group` collapse to one item labelled by
|
|
180
|
+
* the group's primary (its first non-comparison member).
|
|
100
181
|
* @param seriesData - The series data to process
|
|
101
182
|
* @param getElementStyles - Function to get element styles
|
|
102
183
|
* @param showValues - Whether to show values in legend
|
|
103
184
|
* @param withGlyph - Whether to include glyph rendering
|
|
104
185
|
* @param glyphSize - Size of the glyph
|
|
186
|
+
* @param collapseGroups - Whether series sharing a group collapse to a single item
|
|
105
187
|
* @param renderGlyph - Component to render the glyph
|
|
106
188
|
* @param legendShape - The shape type for legend items (string literal or React component)
|
|
107
189
|
* @return Array of processed legend items
|
|
@@ -112,27 +194,44 @@ function processSeriesData(
|
|
|
112
194
|
showValues: boolean,
|
|
113
195
|
withGlyph: boolean,
|
|
114
196
|
glyphSize: number,
|
|
197
|
+
collapseGroups: boolean,
|
|
115
198
|
renderGlyph?: < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode,
|
|
116
199
|
legendShape?: LegendShape< SeriesData[], number >
|
|
117
200
|
): BaseLegendItem[] {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
201
|
+
const buildItem = ( member: SeriesGroupMember, seriesLabels: string[] ) =>
|
|
202
|
+
buildSeriesLegendItem(
|
|
203
|
+
member,
|
|
204
|
+
seriesLabels,
|
|
205
|
+
getElementStyles,
|
|
206
|
+
showValues,
|
|
207
|
+
withGlyph,
|
|
208
|
+
glyphSize,
|
|
209
|
+
renderGlyph,
|
|
210
|
+
legendShape
|
|
211
|
+
);
|
|
124
212
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
};
|
|
213
|
+
// Without collapsing there is no reason to bucket by group — mapping series directly keeps the
|
|
214
|
+
// original order, which matters for charts that already interleave grouped series for colour.
|
|
215
|
+
if ( ! collapseGroups ) {
|
|
216
|
+
return seriesData.map( ( series, index ) => buildItem( { series, index }, [ series.label ] ) );
|
|
217
|
+
}
|
|
131
218
|
|
|
132
|
-
|
|
133
|
-
|
|
219
|
+
return groupSeriesForLegend( seriesData ).flatMap( members => {
|
|
220
|
+
if ( members.length > 1 ) {
|
|
221
|
+
const primary =
|
|
222
|
+
members.find( ( { series } ) => series.options?.type !== 'comparison' ) ?? members[ 0 ];
|
|
223
|
+
|
|
224
|
+
// Primary first so the interactive visibility check reads the series that owns the swatch.
|
|
225
|
+
const seriesLabels = [
|
|
226
|
+
primary.series.label,
|
|
227
|
+
...members.filter( member => member !== primary ).map( ( { series } ) => series.label ),
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
return [ buildItem( primary, seriesLabels ) ];
|
|
231
|
+
}
|
|
134
232
|
|
|
135
|
-
|
|
233
|
+
return members.map( member => buildItem( member, [ member.series.label ] ) );
|
|
234
|
+
} );
|
|
136
235
|
}
|
|
137
236
|
|
|
138
237
|
/**
|
|
@@ -196,6 +295,7 @@ export function useChartLegendItems<
|
|
|
196
295
|
legendValueDisplay = 'percentage',
|
|
197
296
|
withGlyph = false,
|
|
198
297
|
glyphSize = 8,
|
|
298
|
+
collapseGroups = false,
|
|
199
299
|
renderGlyph,
|
|
200
300
|
} = options;
|
|
201
301
|
const { getElementStyles } = useGlobalChartsContext();
|
|
@@ -213,6 +313,7 @@ export function useChartLegendItems<
|
|
|
213
313
|
showValues,
|
|
214
314
|
withGlyph,
|
|
215
315
|
glyphSize,
|
|
316
|
+
collapseGroups,
|
|
216
317
|
renderGlyph,
|
|
217
318
|
legendShape
|
|
218
319
|
);
|
|
@@ -236,6 +337,7 @@ export function useChartLegendItems<
|
|
|
236
337
|
legendValueDisplay,
|
|
237
338
|
withGlyph,
|
|
238
339
|
glyphSize,
|
|
340
|
+
collapseGroups,
|
|
239
341
|
renderGlyph,
|
|
240
342
|
legendShape,
|
|
241
343
|
] );
|
|
@@ -116,9 +116,17 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
116
116
|
|
|
117
117
|
// Handle legend item clicks for interactive mode
|
|
118
118
|
const handleLegendClick = useCallback(
|
|
119
|
-
(
|
|
119
|
+
( seriesLabels: string[] ) => {
|
|
120
120
|
if ( interactive && chartId && context ) {
|
|
121
|
-
|
|
121
|
+
// Converge the whole group on the representative's next state: only toggle series that
|
|
122
|
+
// currently match it, so a desynced group (e.g. one member hidden programmatically)
|
|
123
|
+
// ends up uniformly hidden/shown after one click.
|
|
124
|
+
const representativeVisible = context.isSeriesVisible( chartId, seriesLabels[ 0 ] );
|
|
125
|
+
seriesLabels.forEach( label => {
|
|
126
|
+
if ( context.isSeriesVisible( chartId, label ) === representativeVisible ) {
|
|
127
|
+
context.toggleSeriesVisibility( chartId, label );
|
|
128
|
+
}
|
|
129
|
+
} );
|
|
122
130
|
}
|
|
123
131
|
},
|
|
124
132
|
[ interactive, chartId, context ]
|
|
@@ -137,24 +145,24 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
137
145
|
|
|
138
146
|
// Create event handlers to avoid inline arrow functions
|
|
139
147
|
const createClickHandler = useCallback(
|
|
140
|
-
(
|
|
148
|
+
( seriesLabels: string[] ) => {
|
|
141
149
|
if ( ! interactive ) {
|
|
142
150
|
return undefined;
|
|
143
151
|
}
|
|
144
|
-
return () => handleLegendClick(
|
|
152
|
+
return () => handleLegendClick( seriesLabels );
|
|
145
153
|
},
|
|
146
154
|
[ interactive, handleLegendClick ]
|
|
147
155
|
);
|
|
148
156
|
|
|
149
157
|
const createKeyDownHandler = useCallback(
|
|
150
|
-
(
|
|
158
|
+
( seriesLabels: string[] ) => {
|
|
151
159
|
if ( ! interactive ) {
|
|
152
160
|
return undefined;
|
|
153
161
|
}
|
|
154
162
|
return ( event: KeyboardEvent ) => {
|
|
155
163
|
if ( event.key === 'Enter' || event.key === ' ' ) {
|
|
156
164
|
event.preventDefault();
|
|
157
|
-
handleLegendClick(
|
|
165
|
+
handleLegendClick( seriesLabels );
|
|
158
166
|
}
|
|
159
167
|
};
|
|
160
168
|
},
|
|
@@ -185,10 +193,14 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
185
193
|
style={ theme.legend?.containerStyles }
|
|
186
194
|
>
|
|
187
195
|
{ labels.map( ( label, i ) => {
|
|
188
|
-
const visible = isSeriesVisible( label.text );
|
|
189
|
-
const handleClick = createClickHandler( label.text );
|
|
190
|
-
const handleKeyDown = createKeyDownHandler( label.text );
|
|
191
196
|
const matchedItem = items[ i ];
|
|
197
|
+
// A grouped item toggles/reads every series it controls; a plain item just its own.
|
|
198
|
+
const seriesLabels = matchedItem?.seriesLabels?.length
|
|
199
|
+
? matchedItem.seriesLabels
|
|
200
|
+
: [ label.text ];
|
|
201
|
+
const visible = isSeriesVisible( seriesLabels[ 0 ] );
|
|
202
|
+
const handleClick = createClickHandler( seriesLabels );
|
|
203
|
+
const handleKeyDown = createKeyDownHandler( seriesLabels );
|
|
192
204
|
|
|
193
205
|
return (
|
|
194
206
|
<LegendItem
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChartLegendConfig,
|
|
3
|
+
SeriesChartLegendConfig,
|
|
4
|
+
DataPointPercentage,
|
|
5
|
+
} from '../../../types';
|
|
6
|
+
|
|
7
|
+
// Type-level tests. The assertions that matter are the `@ts-expect-error` directives, which the
|
|
8
|
+
// package typecheck enforces — an unused directive is itself a compile error, so these fail loudly
|
|
9
|
+
// if `collapseGroups` ever leaks onto the base config that point-based charts use.
|
|
10
|
+
describe( 'collapseGroups legend config', () => {
|
|
11
|
+
test( 'is available on the series legend config', () => {
|
|
12
|
+
const seriesLegend: SeriesChartLegendConfig = { collapseGroups: true };
|
|
13
|
+
|
|
14
|
+
expect( seriesLegend.collapseGroups ).toBe( true );
|
|
15
|
+
} );
|
|
16
|
+
|
|
17
|
+
test( 'is not part of the base config point charts use', () => {
|
|
18
|
+
const pieLegend: ChartLegendConfig< DataPointPercentage[] > = {
|
|
19
|
+
// @ts-expect-error collapseGroups is only on SeriesChartLegendConfig — pie segments carry
|
|
20
|
+
// `group` purely to coordinate colours with other charts, not to collapse legend rows.
|
|
21
|
+
collapseGroups: true,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
expect( pieLegend ).toBeDefined();
|
|
25
|
+
} );
|
|
26
|
+
|
|
27
|
+
test( 'leaves other legend options available on the base config', () => {
|
|
28
|
+
const pieLegend: ChartLegendConfig< DataPointPercentage[] > = { interactive: true };
|
|
29
|
+
|
|
30
|
+
expect( pieLegend.interactive ).toBe( true );
|
|
31
|
+
} );
|
|
32
|
+
} );
|
|
@@ -3,7 +3,12 @@ import { render, screen } from '@testing-library/react';
|
|
|
3
3
|
import userEvent from '@testing-library/user-event';
|
|
4
4
|
import { useMemo } from 'react';
|
|
5
5
|
import { SingleChartContext } from '../../../charts/private/single-chart-context';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
GlobalChartsProvider,
|
|
8
|
+
useChartId,
|
|
9
|
+
useChartRegistration,
|
|
10
|
+
useGlobalChartsContext,
|
|
11
|
+
} from '../../../providers';
|
|
7
12
|
import { Legend } from '../legend';
|
|
8
13
|
import { BaseLegend } from '../private/base-legend';
|
|
9
14
|
import type { ChartType } from '../../../types';
|
|
@@ -632,5 +637,120 @@ describe( 'BaseLegend', () => {
|
|
|
632
637
|
// Should still be visible (pressed)
|
|
633
638
|
expect( legendItems[ 0 ] ).toHaveAttribute( 'aria-pressed', 'true' );
|
|
634
639
|
} );
|
|
640
|
+
|
|
641
|
+
it( 'toggles every series in a group when a grouped item is clicked', async () => {
|
|
642
|
+
const user = userEvent.setup();
|
|
643
|
+
|
|
644
|
+
const groupedItems = [
|
|
645
|
+
{ label: 'Views', color: '#0000ff', seriesLabels: [ 'Views', 'Views — previous' ] },
|
|
646
|
+
{
|
|
647
|
+
label: 'Visitors',
|
|
648
|
+
color: '#00ff00',
|
|
649
|
+
seriesLabels: [ 'Visitors', 'Visitors — previous' ],
|
|
650
|
+
},
|
|
651
|
+
];
|
|
652
|
+
|
|
653
|
+
const VisibilityProbe = () => {
|
|
654
|
+
const { isSeriesVisible } = useGlobalChartsContext();
|
|
655
|
+
return (
|
|
656
|
+
<div>
|
|
657
|
+
<span data-testid="views">{ String( isSeriesVisible( 'test-chart', 'Views' ) ) }</span>
|
|
658
|
+
<span data-testid="views-prev">
|
|
659
|
+
{ String( isSeriesVisible( 'test-chart', 'Views — previous' ) ) }
|
|
660
|
+
</span>
|
|
661
|
+
<span data-testid="visitors">
|
|
662
|
+
{ String( isSeriesVisible( 'test-chart', 'Visitors' ) ) }
|
|
663
|
+
</span>
|
|
664
|
+
</div>
|
|
665
|
+
);
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
render(
|
|
669
|
+
<GlobalChartsProvider>
|
|
670
|
+
<BaseLegend items={ groupedItems } interactive={ true } chartId="test-chart" />
|
|
671
|
+
<VisibilityProbe />
|
|
672
|
+
</GlobalChartsProvider>
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
expect( screen.getByTestId( 'views' ) ).toHaveTextContent( 'true' );
|
|
676
|
+
expect( screen.getByTestId( 'views-prev' ) ).toHaveTextContent( 'true' );
|
|
677
|
+
|
|
678
|
+
const buttons = screen.getAllByRole( 'button' );
|
|
679
|
+
await user.click( buttons[ 0 ] );
|
|
680
|
+
|
|
681
|
+
// One click hides both series in the Views group, leaving Visitors untouched.
|
|
682
|
+
expect( screen.getByTestId( 'views' ) ).toHaveTextContent( 'false' );
|
|
683
|
+
expect( screen.getByTestId( 'views-prev' ) ).toHaveTextContent( 'false' );
|
|
684
|
+
expect( screen.getByTestId( 'visitors' ) ).toHaveTextContent( 'true' );
|
|
685
|
+
expect( buttons[ 0 ] ).toHaveAttribute( 'aria-pressed', 'false' );
|
|
686
|
+
} );
|
|
687
|
+
|
|
688
|
+
it( 'converges a desynced group to a uniform state in one click', async () => {
|
|
689
|
+
const user = userEvent.setup();
|
|
690
|
+
|
|
691
|
+
const groupedItems = [
|
|
692
|
+
{ label: 'Views', color: '#0000ff', seriesLabels: [ 'Views', 'Views — previous' ] },
|
|
693
|
+
];
|
|
694
|
+
|
|
695
|
+
const DesyncProbe = () => {
|
|
696
|
+
const { isSeriesVisible, toggleSeriesVisibility } = useGlobalChartsContext();
|
|
697
|
+
return (
|
|
698
|
+
<div>
|
|
699
|
+
<button
|
|
700
|
+
data-testid="hide-previous"
|
|
701
|
+
onClick={ () => toggleSeriesVisibility( 'test-chart', 'Views — previous' ) }
|
|
702
|
+
>
|
|
703
|
+
hide previous
|
|
704
|
+
</button>
|
|
705
|
+
<span data-testid="views">{ String( isSeriesVisible( 'test-chart', 'Views' ) ) }</span>
|
|
706
|
+
<span data-testid="views-prev">
|
|
707
|
+
{ String( isSeriesVisible( 'test-chart', 'Views — previous' ) ) }
|
|
708
|
+
</span>
|
|
709
|
+
</div>
|
|
710
|
+
);
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
render(
|
|
714
|
+
<GlobalChartsProvider>
|
|
715
|
+
<BaseLegend items={ groupedItems } interactive={ true } chartId="test-chart" />
|
|
716
|
+
<DesyncProbe />
|
|
717
|
+
</GlobalChartsProvider>
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
// Desync the group: hide only the comparison series programmatically.
|
|
721
|
+
await user.click( screen.getByTestId( 'hide-previous' ) );
|
|
722
|
+
expect( screen.getByTestId( 'views' ) ).toHaveTextContent( 'true' );
|
|
723
|
+
expect( screen.getByTestId( 'views-prev' ) ).toHaveTextContent( 'false' );
|
|
724
|
+
|
|
725
|
+
// Clicking the grouped item hides the whole group, not inverts each member.
|
|
726
|
+
const legendButton = screen.getAllByRole( 'button' )[ 0 ];
|
|
727
|
+
await user.click( legendButton );
|
|
728
|
+
expect( screen.getByTestId( 'views' ) ).toHaveTextContent( 'false' );
|
|
729
|
+
expect( screen.getByTestId( 'views-prev' ) ).toHaveTextContent( 'false' );
|
|
730
|
+
|
|
731
|
+
// And the next click shows the whole group again.
|
|
732
|
+
await user.click( legendButton );
|
|
733
|
+
expect( screen.getByTestId( 'views' ) ).toHaveTextContent( 'true' );
|
|
734
|
+
expect( screen.getByTestId( 'views-prev' ) ).toHaveTextContent( 'true' );
|
|
735
|
+
} );
|
|
736
|
+
|
|
737
|
+
it( 'falls back to the item label when seriesLabels is an empty array', async () => {
|
|
738
|
+
const user = userEvent.setup();
|
|
739
|
+
|
|
740
|
+
const items = [ { label: 'Views', color: '#0000ff', seriesLabels: [] as string[] } ];
|
|
741
|
+
|
|
742
|
+
render(
|
|
743
|
+
<GlobalChartsProvider>
|
|
744
|
+
<BaseLegend items={ items } interactive={ true } chartId="test-chart" />
|
|
745
|
+
</GlobalChartsProvider>
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
const legendButton = screen.getByRole( 'button' );
|
|
749
|
+
expect( legendButton ).toHaveAttribute( 'aria-pressed', 'true' );
|
|
750
|
+
|
|
751
|
+
await user.click( legendButton );
|
|
752
|
+
|
|
753
|
+
expect( legendButton ).toHaveAttribute( 'aria-pressed', 'false' );
|
|
754
|
+
} );
|
|
635
755
|
} );
|
|
636
756
|
} );
|
|
@@ -55,4 +55,10 @@ export type BaseLegendItem = {
|
|
|
55
55
|
glyphSize?: number;
|
|
56
56
|
renderGlyph?: < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode;
|
|
57
57
|
shapeStyle?: CSSProperties & LineStyles;
|
|
58
|
+
/**
|
|
59
|
+
* The series labels this legend item controls. For a grouped item this is every series
|
|
60
|
+
* sharing the group (primary first); for a single series it is just its own label. Used by
|
|
61
|
+
* the interactive legend to toggle a whole group's visibility from one item.
|
|
62
|
+
*/
|
|
63
|
+
seriesLabels?: string[];
|
|
58
64
|
};
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -608,6 +608,22 @@ export type ChartLegendConfig< T = DataPoint | DataPointDate | LeaderboardEntry
|
|
|
608
608
|
shapeStyles?: LegendShapeStyles;
|
|
609
609
|
};
|
|
610
610
|
|
|
611
|
+
/**
|
|
612
|
+
* Legend config for charts built from `SeriesData` (line, bar, area). Adds `collapseGroups` on top
|
|
613
|
+
* of the shared config. It is intentionally absent from the base `ChartLegendConfig` so point-based
|
|
614
|
+
* charts (pie, semi-circle pie) — whose data points carry `group` only to coordinate colours — can't
|
|
615
|
+
* set it.
|
|
616
|
+
*/
|
|
617
|
+
export type SeriesChartLegendConfig = ChartLegendConfig< SeriesData[] > & {
|
|
618
|
+
/**
|
|
619
|
+
* Collapse series that share a `group` into a single legend item, labelled by the group's
|
|
620
|
+
* primary series (its first non-comparison member). Off by default, so every series keeps its
|
|
621
|
+
* own item. Combines with `interactive`: a collapsed item toggles every series in its group, an
|
|
622
|
+
* uncollapsed one toggles only its own.
|
|
623
|
+
*/
|
|
624
|
+
collapseGroups?: boolean;
|
|
625
|
+
};
|
|
626
|
+
|
|
611
627
|
/**
|
|
612
628
|
* Base properties shared across all chart components
|
|
613
629
|
*/
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
.x-zoom {
|
|
2
|
-
|
|
3
|
-
// The zoom control's bespoke colours are inlined, not exposed as
|
|
4
|
-
// --a8c-charts-color-zoom-* override vars. The control is slated for rework
|
|
5
|
-
// (reset button -> @wordpress/ui Button), so it is deliberately not a public
|
|
6
|
-
// theming surface. Focus tracks the shared --a8c-charts-color-focus role; the
|
|
7
|
-
// button text uses the WPDS content-foreground token.
|
|
8
|
-
&__selection {
|
|
9
|
-
fill: rgba(56, 88, 233, 0.16);
|
|
10
|
-
stroke: rgba(56, 88, 233, 0.65);
|
|
11
|
-
stroke-width: var(--wpds-border-width-xs, 1px);
|
|
12
|
-
pointer-events: none;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
&__reset {
|
|
16
|
-
position: absolute;
|
|
17
|
-
top: var(--wpds-dimension-gap-sm, 8px);
|
|
18
|
-
right: var(--wpds-dimension-gap-sm, 8px);
|
|
19
|
-
z-index: 2;
|
|
20
|
-
display: inline-flex;
|
|
21
|
-
align-items: center;
|
|
22
|
-
justify-content: center;
|
|
23
|
-
width: 28px;
|
|
24
|
-
height: 28px;
|
|
25
|
-
padding: 0;
|
|
26
|
-
background: rgba(255, 255, 255, 0.92);
|
|
27
|
-
color: var(--wpds-color-foreground-content-neutral, #1e1e1e);
|
|
28
|
-
border:
|
|
29
|
-
var(--wpds-border-width-xs, 1px) solid
|
|
30
|
-
rgba(0, 0, 0, 0.16);
|
|
31
|
-
border-radius: var(--wpds-border-radius-md, 4px);
|
|
32
|
-
cursor: pointer;
|
|
33
|
-
box-shadow: var(--wpds-elevation-xs, 0 1px 1px 0 #00000008, 0 1px 2px 0 #00000005, 0 3px 3px 0 #00000005, 0 4px 4px 0 #00000003);
|
|
34
|
-
|
|
35
|
-
&:hover {
|
|
36
|
-
background: rgba(255, 255, 255, 1);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
&:focus-visible {
|
|
40
|
-
outline: 2px solid var(--a8c-charts-color-focus, var(--wpds-color-stroke-focus, #3858e9));
|
|
41
|
-
outline-offset: 1px;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
&__reset-icon {
|
|
46
|
-
width: 16px;
|
|
47
|
-
height: 16px;
|
|
48
|
-
flex-shrink: 0;
|
|
49
|
-
}
|
|
50
|
-
}
|