Skip to content

Commit fc4f676

Browse files
authored
[VBC] Select multiple legends for Vertical bar chart (#33510)
1 parent 18396b7 commit fc4f676

File tree

5 files changed

+180
-55
lines changed

5 files changed

+180
-55
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "Select multiple legends for Vertical bar chart",
4+
"packageName": "@fluentui/react-charting",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}

packages/charts/react-charting/src/components/DeclarativeChart/DeclarativeChart.tsx

+11-5
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,18 @@ export const DeclarativeChart: React.FunctionComponent<DeclarativeChartProps> =
142142
[exportAsImage],
143143
);
144144

145+
const multiSelectLegendProps = {
146+
...legendProps,
147+
canSelectMultipleLegends: true,
148+
selectedLegends: activeLegends,
149+
};
150+
145151
switch (data[0].type) {
146152
case 'pie':
147153
return (
148154
<DonutChart
149155
{...transformPlotlyJsonToDonutProps(plotlySchema, colorMap, isDarkTheme)}
150-
legendProps={{ ...legendProps, canSelectMultipleLegends: true, selectedLegends: activeLegends }}
156+
legendProps={multiSelectLegendProps}
151157
componentRef={chartRef}
152158
// Bubble event to prevent right click to open menu on the callout
153159
calloutProps={{ layerProps: { eventBubblingEnabled: true } }}
@@ -169,7 +175,7 @@ export const DeclarativeChart: React.FunctionComponent<DeclarativeChartProps> =
169175
return (
170176
<GroupedVerticalBarChart
171177
{...transformPlotlyJsonToGVBCProps(plotlySchema, colorMap, isDarkTheme)}
172-
legendProps={{ ...legendProps, canSelectMultipleLegends: true, selectedLegends: activeLegends }}
178+
legendProps={multiSelectLegendProps}
173179
componentRef={chartRef}
174180
calloutProps={{ layerProps: { eventBubblingEnabled: true } }}
175181
/>
@@ -178,7 +184,7 @@ export const DeclarativeChart: React.FunctionComponent<DeclarativeChartProps> =
178184
return (
179185
<VerticalStackedBarChart
180186
{...transformPlotlyJsonToVSBCProps(plotlySchema, colorMap, isDarkTheme)}
181-
legendProps={{ ...legendProps, canSelectMultipleLegends: true, selectedLegends: activeLegends }}
187+
legendProps={multiSelectLegendProps}
182188
componentRef={chartRef}
183189
calloutProps={{ layerProps: { eventBubblingEnabled: true } }}
184190
/>
@@ -227,7 +233,7 @@ export const DeclarativeChart: React.FunctionComponent<DeclarativeChartProps> =
227233
return (
228234
<VerticalStackedBarChart
229235
{...transformPlotlyJsonToVSBCProps(plotlySchema, colorMap, isDarkTheme)}
230-
legendProps={{ ...legendProps, canSelectMultipleLegends: true, selectedLegends: activeLegends }}
236+
legendProps={multiSelectLegendProps}
231237
componentRef={chartRef}
232238
calloutProps={{ layerProps: { eventBubblingEnabled: true } }}
233239
/>
@@ -265,7 +271,7 @@ export const DeclarativeChart: React.FunctionComponent<DeclarativeChartProps> =
265271
return (
266272
<VerticalBarChart
267273
{...transformPlotlyJsonToVBCProps(plotlySchema, colorMap, isDarkTheme)}
268-
legendProps={legendProps}
274+
legendProps={multiSelectLegendProps}
269275
componentRef={chartRef}
270276
calloutProps={{ layerProps: { eventBubblingEnabled: true } }}
271277
/>

packages/charts/react-charting/src/components/VerticalBarChart/VerticalBarChart.base.tsx

+62-50
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
createStringYAxis,
5353
formatDate,
5454
getNextGradient,
55+
areArraysEqual,
5556
} from '../../utilities/index';
5657
import { IChart } from '../../types/index';
5758

@@ -73,6 +74,7 @@ export interface IVerticalBarChartState extends IBasestate {
7374
hoverXValue?: string | number | null;
7475
callOutAccessibilityData?: IAccessibilityProps;
7576
calloutLegend: string;
77+
selectedLegends: string[];
7678
}
7779

7880
type ColorScale = (_p?: number) => string;
@@ -118,8 +120,8 @@ export class VerticalBarChartBase
118120
dataForHoverCard: 0,
119121
isCalloutVisible: false,
120122
refSelected: null,
121-
selectedLegend: props.legendProps?.selectedLegend ?? '',
122-
activeLegend: '',
123+
selectedLegends: props.legendProps?.selectedLegends || [],
124+
activeLegend: undefined,
123125
xCalloutValue: '',
124126
yCalloutValue: '',
125127
activeXdataPoint: null,
@@ -141,9 +143,9 @@ export class VerticalBarChartBase
141143
}
142144

143145
public componentDidUpdate(prevProps: IVerticalBarChartProps): void {
144-
if (prevProps.legendProps?.selectedLegend !== this.props.legendProps?.selectedLegend) {
146+
if (!areArraysEqual(prevProps.legendProps?.selectedLegends, this.props.legendProps?.selectedLegends)) {
145147
this.setState({
146-
selectedLegend: this.props.legendProps?.selectedLegend ?? '',
148+
selectedLegends: this.props.legendProps?.selectedLegends || [],
147149
});
148150
}
149151
}
@@ -199,7 +201,8 @@ export class VerticalBarChartBase
199201
createYAxis={createNumericYAxis}
200202
calloutProps={calloutProps}
201203
tickParams={tickParams}
202-
{...(this._isHavingLine && this._noLegendHighlighted() && { isCalloutForStack: true })}
204+
{...(this._isHavingLine &&
205+
(this._noLegendHighlighted() || this._getHighlightedLegend().length > 1) && { isCalloutForStack: true })}
203206
legendBars={legendBars}
204207
datasetForXAxisDomain={this._xAxisLabels}
205208
barwidth={this._barWidth}
@@ -404,11 +407,11 @@ export class VerticalBarChartBase
404407
xAxisPoint: string | number | Date,
405408
legend: string,
406409
): { visibility: CircleVisbility; radius: number } => {
407-
const { selectedLegend, activeXdataPoint } = this.state;
408-
if (selectedLegend !== '') {
409-
if (xAxisPoint === activeXdataPoint && selectedLegend === legend) {
410+
const { activeXdataPoint } = this.state;
411+
if (!this._noLegendHighlighted()) {
412+
if (xAxisPoint === activeXdataPoint && this._legendHighlighted(legend)) {
410413
return { visibility: CircleVisbility.show, radius: 8 };
411-
} else if (selectedLegend === legend) {
414+
} else if (this._legendHighlighted(legend)) {
412415
// Don't hide the circle to keep it focusable. For more information,
413416
// see https://fuzzbomb.github.io/accessibility-demos/visually-hidden-focus-test.html
414417
return { visibility: CircleVisbility.show, radius: 0.3 };
@@ -539,7 +542,11 @@ export class VerticalBarChartBase
539542
: this._createColors()(1);
540543

541544
// there might be no y value of the line for the hovered bar. so we need to check this condition
542-
if (this._isHavingLine && selectedPoint[0].lineData?.y !== undefined) {
545+
if (
546+
this._isHavingLine &&
547+
selectedPoint[0].lineData?.y !== undefined &&
548+
(this._legendHighlighted(lineLegendText) || this._noLegendHighlighted())
549+
) {
543550
// callout data for the line
544551
YValueHover.push({
545552
legend: lineLegendText,
@@ -549,18 +556,20 @@ export class VerticalBarChartBase
549556
yAxisCalloutData: selectedPoint[0].lineData?.yAxisCalloutData,
550557
});
551558
}
552-
// callout data for the bar
553-
YValueHover.push({
554-
legend: selectedPoint[0].legend,
555-
y: selectedPoint[0].y,
556-
color: enableGradient
557-
? useSingleColor
558-
? getNextGradient(0, 0)[0]
559-
: selectedPoint[0].gradient?.[0] || getNextGradient(pointIndex, 0)[0]
560-
: calloutColor,
561-
data: selectedPoint[0].yAxisCalloutData,
562-
yAxisCalloutData: selectedPoint[0].yAxisCalloutData,
563-
});
559+
if (this._legendHighlighted(selectedPoint[0].legend) || this._noLegendHighlighted()) {
560+
// callout data for the bar
561+
YValueHover.push({
562+
legend: selectedPoint[0].legend,
563+
y: selectedPoint[0].y,
564+
color: enableGradient
565+
? useSingleColor
566+
? getNextGradient(0, 0)[0]
567+
: selectedPoint[0].gradient?.[0] || getNextGradient(pointIndex, 0)[0]
568+
: calloutColor,
569+
data: selectedPoint[0].yAxisCalloutData,
570+
yAxisCalloutData: selectedPoint[0].yAxisCalloutData,
571+
});
572+
}
564573
const hoverXValue = point.x instanceof Date ? formatDate(point.x, this.props.useUTC) : point.x.toString();
565574
return {
566575
YValueHover,
@@ -581,7 +590,7 @@ export class VerticalBarChartBase
581590
this.setState({
582591
refSelected: mouseEvent,
583592
/** Show the callout if highlighted bar is hovered and Hide it if unhighlighted bar is hovered */
584-
isCalloutVisible: this.state.selectedLegend === '' || this.state.selectedLegend === point.legend,
593+
isCalloutVisible: this._noLegendHighlighted() || this._legendHighlighted(point.legend),
585594
dataForHoverCard: point.y,
586595
calloutLegend: point.legend!,
587596
color: point.color || color,
@@ -592,7 +601,7 @@ export class VerticalBarChartBase
592601
yCalloutValue: point.yAxisCalloutData!,
593602
dataPointCalloutProps: point,
594603
// Hovering over a bar should highlight corresponding line points only when no legend is selected
595-
activeXdataPoint: this._noLegendHighlighted() ? point.x : null,
604+
activeXdataPoint: this._noLegendHighlighted() || this._legendHighlighted(point.legend) ? point.x : null,
596605
YValueHover,
597606
hoverXValue,
598607
callOutAccessibilityData: point.callOutAccessibilityData,
@@ -621,7 +630,7 @@ export class VerticalBarChartBase
621630
this.setState({
622631
refSelected: obj.refElement,
623632
/** Show the callout if highlighted bar is focused and Hide it if unhighlighted bar is focused */
624-
isCalloutVisible: this.state.selectedLegend === '' || this.state.selectedLegend === point.legend,
633+
isCalloutVisible: this._noLegendHighlighted() || this._legendHighlighted(point.legend),
625634
calloutLegend: point.legend!,
626635
dataForHoverCard: point.y,
627636
color: point.color || color,
@@ -1059,18 +1068,6 @@ export class VerticalBarChartBase
10591068
});
10601069
};
10611070

1062-
private _onLegendClick(legendTitle: string): void {
1063-
if (this.state.selectedLegend === legendTitle) {
1064-
this.setState({
1065-
selectedLegend: '',
1066-
});
1067-
} else {
1068-
this.setState({
1069-
selectedLegend: legendTitle,
1070-
});
1071-
}
1072-
}
1073-
10741071
private _onLegendHover(legendTitle: string): void {
10751072
this.setState({
10761073
activeLegend: legendTitle,
@@ -1079,7 +1076,7 @@ export class VerticalBarChartBase
10791076

10801077
private _onLegendLeave(): void {
10811078
this.setState({
1082-
activeLegend: '',
1079+
activeLegend: undefined,
10831080
});
10841081
}
10851082

@@ -1106,9 +1103,6 @@ export class VerticalBarChartBase
11061103
const legend: ILegend = {
11071104
title: legendTitle,
11081105
color,
1109-
action: () => {
1110-
this._onLegendClick(legendTitle);
1111-
},
11121106
hoverAction: () => {
11131107
this._handleChartMouseLeave();
11141108
this._onLegendHover(legendTitle);
@@ -1123,9 +1117,6 @@ export class VerticalBarChartBase
11231117
const lineLegend: ILegend = {
11241118
title: lineLegendText,
11251119
color: lineLegendColor,
1126-
action: () => {
1127-
this._onLegendClick(lineLegendText);
1128-
},
11291120
hoverAction: () => {
11301121
this._handleChartMouseLeave();
11311122
this._onLegendHover(lineLegendText);
@@ -1145,11 +1136,27 @@ export class VerticalBarChartBase
11451136
focusZonePropsInHoverCard={this.props.focusZonePropsForLegendsInHoverCard}
11461137
overflowText={this.props.legendsOverflowText}
11471138
{...this.props.legendProps}
1139+
onChange={this._onLegendSelectionChange.bind(this)}
11481140
/>
11491141
);
11501142
return legends;
11511143
};
11521144

1145+
private _onLegendSelectionChange(
1146+
selectedLegends: string[],
1147+
event: React.MouseEvent<HTMLButtonElement>,
1148+
currentLegend?: ILegend,
1149+
): void {
1150+
if (this.props.legendProps?.canSelectMultipleLegends) {
1151+
this.setState({ selectedLegends });
1152+
} else {
1153+
this.setState({ selectedLegends: selectedLegends.slice(-1) });
1154+
}
1155+
if (this.props.legendProps?.onChange) {
1156+
this.props.legendProps.onChange(selectedLegends, event, currentLegend);
1157+
}
1158+
}
1159+
11531160
private _getAxisData = (yAxisData: IAxisData) => {
11541161
if (yAxisData && yAxisData.yAxisDomainValues.length) {
11551162
const { yAxisDomainValues: domainValue } = yAxisData;
@@ -1164,20 +1171,25 @@ export class VerticalBarChartBase
11641171
* 1. selection: if the user clicks on it
11651172
* 2. hovering: if there is no selected legend and the user hovers over it
11661173
*/
1167-
private _legendHighlighted = (legendTitle: string) => {
1168-
return (
1169-
this.state.selectedLegend === legendTitle ||
1170-
(this.state.selectedLegend === '' && this.state.activeLegend === legendTitle)
1171-
);
1174+
private _legendHighlighted = (legendTitle: string | undefined) => {
1175+
return this._getHighlightedLegend().includes(legendTitle!);
11721176
};
11731177

11741178
/**
11751179
* This function checks if none of the legends is selected or hovered.
11761180
*/
11771181
private _noLegendHighlighted = () => {
1178-
return this.state.selectedLegend === '' && this.state.activeLegend === '';
1182+
return this._getHighlightedLegend().length === 0;
11791183
};
11801184

1185+
private _getHighlightedLegend() {
1186+
return this.state.selectedLegends.length > 0
1187+
? this.state.selectedLegends
1188+
: this.state.activeLegend
1189+
? [this.state.activeLegend]
1190+
: [];
1191+
}
1192+
11811193
private _getAriaLabel = (point: IVerticalBarChartDataPoint): string => {
11821194
const xValue = point.xAxisCalloutData
11831195
? point.xAxisCalloutData

packages/charts/react-charting/src/components/VerticalBarChart/VerticalBarChartRTL.test.tsx

+81
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,87 @@ describe('Vertical bar chart - Subcomponent Legends', () => {
652652
expect(bars[7]).toHaveStyle('opacity: 0.1');
653653
},
654654
);
655+
656+
testWithWait(
657+
'Should reduce the opacity of the other bars/lines and their legends on mouse over multiple legends',
658+
VerticalBarChart,
659+
{ data: pointsWithLine, lineLegendText: 'just line', legendProps: { canSelectMultipleLegends: true } },
660+
container => {
661+
const bars = getById(container, /_VBC_bar/i);
662+
const line = getById(container, /_VBC_line/i)[0];
663+
const legends = screen.getAllByText((content, element) => element!.tagName.toLowerCase() === 'button');
664+
expect(line).toBeDefined();
665+
expect(bars).toHaveLength(8);
666+
expect(legends).toHaveLength(9);
667+
fireEvent.click(screen.getByText('just line'));
668+
fireEvent.click(screen.getByText('Oranges'));
669+
expect(line.getAttribute('opacity')).toEqual('1');
670+
expect(screen.getByText('Oranges')).not.toHaveAttribute('opacity');
671+
expect(screen.getByText('Dogs')).toHaveStyle('opacity: 0.67');
672+
expect(screen.getByText('Apples')).toHaveStyle('opacity: 0.67');
673+
expect(screen.getByText('Bananas')).toHaveStyle('opacity: 0.67');
674+
expect(screen.getByText('Giraffes')).toHaveStyle('opacity: 0.67');
675+
expect(screen.getByText('Cats')).toHaveStyle('opacity: 0.67');
676+
expect(screen.getByText('Elephants')).toHaveStyle('opacity: 0.67');
677+
expect(screen.getByText('Monkeys')).toHaveStyle('opacity: 0.67');
678+
expect(line).toBeDefined();
679+
expect(bars[0]).toBeDefined();
680+
expect(bars[0]).not.toHaveAttribute('opacity');
681+
expect(bars[1]).toBeDefined();
682+
expect(bars[1]).toHaveStyle('opacity: 0.1');
683+
expect(bars[2]).toBeDefined();
684+
expect(bars[2]).toHaveStyle('opacity: 0.1');
685+
expect(bars[3]).toBeDefined();
686+
expect(bars[3]).toHaveStyle('opacity: 0.1');
687+
expect(bars[4]).toBeDefined();
688+
expect(bars[4]).toHaveStyle('opacity: 0.1');
689+
expect(bars[5]).toBeDefined();
690+
expect(bars[5]).toHaveStyle('opacity: 0.1');
691+
expect(bars[6]).toBeDefined();
692+
expect(bars[6]).toHaveStyle('opacity: 0.1');
693+
expect(bars[7]).toBeDefined();
694+
expect(bars[7]).toHaveStyle('opacity: 0.1');
695+
},
696+
);
697+
698+
testWithWait(
699+
'Should reduce the opacity of the other bars/lines and their legends on mouse over multiple legends',
700+
VerticalBarChart,
701+
{ data: pointsWithLine, lineLegendText: 'just line', legendProps: { canSelectMultipleLegends: true } },
702+
container => {
703+
const bars = getById(container, /_VBC_bar/i);
704+
const line = getById(container, /_VBC_line/i)[0];
705+
const legends = screen.getAllByText((content, element) => element!.tagName.toLowerCase() === 'button');
706+
expect(line).toBeDefined();
707+
expect(bars).toHaveLength(8);
708+
expect(legends).toHaveLength(9);
709+
fireEvent.click(screen.getByText('just line'));
710+
fireEvent.click(screen.getByText('Oranges'));
711+
expect(line.getAttribute('opacity')).toEqual('1');
712+
expect(screen.getByText('Dogs')).toHaveStyle('opacity: 0.67');
713+
expect(screen.getByText('Apples')).toHaveStyle('opacity: 0.67');
714+
expect(screen.getByText('Bananas')).toHaveStyle('opacity: 0.67');
715+
expect(screen.getByText('Giraffes')).toHaveStyle('opacity: 0.67');
716+
expect(screen.getByText('Cats')).toHaveStyle('opacity: 0.67');
717+
expect(screen.getByText('Elephants')).toHaveStyle('opacity: 0.67');
718+
expect(screen.getByText('Monkeys')).toHaveStyle('opacity: 0.67');
719+
expect(line).toBeDefined();
720+
expect(bars[1]).toBeDefined();
721+
expect(bars[1]).toHaveStyle('opacity: 0.1');
722+
expect(bars[2]).toBeDefined();
723+
expect(bars[2]).toHaveStyle('opacity: 0.1');
724+
expect(bars[3]).toBeDefined();
725+
expect(bars[3]).toHaveStyle('opacity: 0.1');
726+
expect(bars[4]).toBeDefined();
727+
expect(bars[4]).toHaveStyle('opacity: 0.1');
728+
expect(bars[5]).toBeDefined();
729+
expect(bars[5]).toHaveStyle('opacity: 0.1');
730+
expect(bars[6]).toBeDefined();
731+
expect(bars[6]).toHaveStyle('opacity: 0.1');
732+
expect(bars[7]).toBeDefined();
733+
expect(bars[7]).toHaveStyle('opacity: 0.1');
734+
},
735+
);
655736
});
656737

657738
describe('Vertical bar chart - Subcomponent callout', () => {

0 commit comments

Comments
 (0)