diff --git a/change/@fluentui-react-charting-6eac8468-8081-4f90-953a-7675bed19f9d.json b/change/@fluentui-react-charting-6eac8468-8081-4f90-953a-7675bed19f9d.json new file mode 100644 index 00000000000000..f9459f534c55b3 --- /dev/null +++ b/change/@fluentui-react-charting-6eac8468-8081-4f90-953a-7675bed19f9d.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Select multiple legends for Vertical bar chart", + "packageName": "@fluentui/react-charting", + "email": "120183316+srmukher@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/charts/react-charting/src/components/DeclarativeChart/DeclarativeChart.tsx b/packages/charts/react-charting/src/components/DeclarativeChart/DeclarativeChart.tsx index 58b49824fa392b..9d39565bf1772a 100644 --- a/packages/charts/react-charting/src/components/DeclarativeChart/DeclarativeChart.tsx +++ b/packages/charts/react-charting/src/components/DeclarativeChart/DeclarativeChart.tsx @@ -142,12 +142,18 @@ export const DeclarativeChart: React.FunctionComponent = [exportAsImage], ); + const multiSelectLegendProps = { + ...legendProps, + canSelectMultipleLegends: true, + selectedLegends: activeLegends, + }; + switch (data[0].type) { case 'pie': return ( = return ( @@ -178,7 +184,7 @@ export const DeclarativeChart: React.FunctionComponent = return ( @@ -227,7 +233,7 @@ export const DeclarativeChart: React.FunctionComponent = return ( @@ -265,7 +271,7 @@ export const DeclarativeChart: React.FunctionComponent = return ( diff --git a/packages/charts/react-charting/src/components/VerticalBarChart/VerticalBarChart.base.tsx b/packages/charts/react-charting/src/components/VerticalBarChart/VerticalBarChart.base.tsx index 5a01b1ac417d69..1f17a5f43ea113 100644 --- a/packages/charts/react-charting/src/components/VerticalBarChart/VerticalBarChart.base.tsx +++ b/packages/charts/react-charting/src/components/VerticalBarChart/VerticalBarChart.base.tsx @@ -52,6 +52,7 @@ import { createStringYAxis, formatDate, getNextGradient, + areArraysEqual, } from '../../utilities/index'; import { IChart } from '../../types/index'; @@ -73,6 +74,7 @@ export interface IVerticalBarChartState extends IBasestate { hoverXValue?: string | number | null; callOutAccessibilityData?: IAccessibilityProps; calloutLegend: string; + selectedLegends: string[]; } type ColorScale = (_p?: number) => string; @@ -118,8 +120,8 @@ export class VerticalBarChartBase dataForHoverCard: 0, isCalloutVisible: false, refSelected: null, - selectedLegend: props.legendProps?.selectedLegend ?? '', - activeLegend: '', + selectedLegends: props.legendProps?.selectedLegends || [], + activeLegend: undefined, xCalloutValue: '', yCalloutValue: '', activeXdataPoint: null, @@ -141,9 +143,9 @@ export class VerticalBarChartBase } public componentDidUpdate(prevProps: IVerticalBarChartProps): void { - if (prevProps.legendProps?.selectedLegend !== this.props.legendProps?.selectedLegend) { + if (!areArraysEqual(prevProps.legendProps?.selectedLegends, this.props.legendProps?.selectedLegends)) { this.setState({ - selectedLegend: this.props.legendProps?.selectedLegend ?? '', + selectedLegends: this.props.legendProps?.selectedLegends || [], }); } } @@ -199,7 +201,8 @@ export class VerticalBarChartBase createYAxis={createNumericYAxis} calloutProps={calloutProps} tickParams={tickParams} - {...(this._isHavingLine && this._noLegendHighlighted() && { isCalloutForStack: true })} + {...(this._isHavingLine && + (this._noLegendHighlighted() || this._getHighlightedLegend().length > 1) && { isCalloutForStack: true })} legendBars={legendBars} datasetForXAxisDomain={this._xAxisLabels} barwidth={this._barWidth} @@ -404,11 +407,11 @@ export class VerticalBarChartBase xAxisPoint: string | number | Date, legend: string, ): { visibility: CircleVisbility; radius: number } => { - const { selectedLegend, activeXdataPoint } = this.state; - if (selectedLegend !== '') { - if (xAxisPoint === activeXdataPoint && selectedLegend === legend) { + const { activeXdataPoint } = this.state; + if (!this._noLegendHighlighted()) { + if (xAxisPoint === activeXdataPoint && this._legendHighlighted(legend)) { return { visibility: CircleVisbility.show, radius: 8 }; - } else if (selectedLegend === legend) { + } else if (this._legendHighlighted(legend)) { // Don't hide the circle to keep it focusable. For more information, // see https://fuzzbomb.github.io/accessibility-demos/visually-hidden-focus-test.html return { visibility: CircleVisbility.show, radius: 0.3 }; @@ -539,7 +542,11 @@ export class VerticalBarChartBase : this._createColors()(1); // there might be no y value of the line for the hovered bar. so we need to check this condition - if (this._isHavingLine && selectedPoint[0].lineData?.y !== undefined) { + if ( + this._isHavingLine && + selectedPoint[0].lineData?.y !== undefined && + (this._legendHighlighted(lineLegendText) || this._noLegendHighlighted()) + ) { // callout data for the line YValueHover.push({ legend: lineLegendText, @@ -549,18 +556,20 @@ export class VerticalBarChartBase yAxisCalloutData: selectedPoint[0].lineData?.yAxisCalloutData, }); } - // callout data for the bar - YValueHover.push({ - legend: selectedPoint[0].legend, - y: selectedPoint[0].y, - color: enableGradient - ? useSingleColor - ? getNextGradient(0, 0)[0] - : selectedPoint[0].gradient?.[0] || getNextGradient(pointIndex, 0)[0] - : calloutColor, - data: selectedPoint[0].yAxisCalloutData, - yAxisCalloutData: selectedPoint[0].yAxisCalloutData, - }); + if (this._legendHighlighted(selectedPoint[0].legend) || this._noLegendHighlighted()) { + // callout data for the bar + YValueHover.push({ + legend: selectedPoint[0].legend, + y: selectedPoint[0].y, + color: enableGradient + ? useSingleColor + ? getNextGradient(0, 0)[0] + : selectedPoint[0].gradient?.[0] || getNextGradient(pointIndex, 0)[0] + : calloutColor, + data: selectedPoint[0].yAxisCalloutData, + yAxisCalloutData: selectedPoint[0].yAxisCalloutData, + }); + } const hoverXValue = point.x instanceof Date ? formatDate(point.x, this.props.useUTC) : point.x.toString(); return { YValueHover, @@ -581,7 +590,7 @@ export class VerticalBarChartBase this.setState({ refSelected: mouseEvent, /** Show the callout if highlighted bar is hovered and Hide it if unhighlighted bar is hovered */ - isCalloutVisible: this.state.selectedLegend === '' || this.state.selectedLegend === point.legend, + isCalloutVisible: this._noLegendHighlighted() || this._legendHighlighted(point.legend), dataForHoverCard: point.y, calloutLegend: point.legend!, color: point.color || color, @@ -592,7 +601,7 @@ export class VerticalBarChartBase yCalloutValue: point.yAxisCalloutData!, dataPointCalloutProps: point, // Hovering over a bar should highlight corresponding line points only when no legend is selected - activeXdataPoint: this._noLegendHighlighted() ? point.x : null, + activeXdataPoint: this._noLegendHighlighted() || this._legendHighlighted(point.legend) ? point.x : null, YValueHover, hoverXValue, callOutAccessibilityData: point.callOutAccessibilityData, @@ -621,7 +630,7 @@ export class VerticalBarChartBase this.setState({ refSelected: obj.refElement, /** Show the callout if highlighted bar is focused and Hide it if unhighlighted bar is focused */ - isCalloutVisible: this.state.selectedLegend === '' || this.state.selectedLegend === point.legend, + isCalloutVisible: this._noLegendHighlighted() || this._legendHighlighted(point.legend), calloutLegend: point.legend!, dataForHoverCard: point.y, color: point.color || color, @@ -1059,18 +1068,6 @@ export class VerticalBarChartBase }); }; - private _onLegendClick(legendTitle: string): void { - if (this.state.selectedLegend === legendTitle) { - this.setState({ - selectedLegend: '', - }); - } else { - this.setState({ - selectedLegend: legendTitle, - }); - } - } - private _onLegendHover(legendTitle: string): void { this.setState({ activeLegend: legendTitle, @@ -1079,7 +1076,7 @@ export class VerticalBarChartBase private _onLegendLeave(): void { this.setState({ - activeLegend: '', + activeLegend: undefined, }); } @@ -1106,9 +1103,6 @@ export class VerticalBarChartBase const legend: ILegend = { title: legendTitle, color, - action: () => { - this._onLegendClick(legendTitle); - }, hoverAction: () => { this._handleChartMouseLeave(); this._onLegendHover(legendTitle); @@ -1123,9 +1117,6 @@ export class VerticalBarChartBase const lineLegend: ILegend = { title: lineLegendText, color: lineLegendColor, - action: () => { - this._onLegendClick(lineLegendText); - }, hoverAction: () => { this._handleChartMouseLeave(); this._onLegendHover(lineLegendText); @@ -1145,11 +1136,27 @@ export class VerticalBarChartBase focusZonePropsInHoverCard={this.props.focusZonePropsForLegendsInHoverCard} overflowText={this.props.legendsOverflowText} {...this.props.legendProps} + onChange={this._onLegendSelectionChange.bind(this)} /> ); return legends; }; + private _onLegendSelectionChange( + selectedLegends: string[], + event: React.MouseEvent, + currentLegend?: ILegend, + ): void { + if (this.props.legendProps?.canSelectMultipleLegends) { + this.setState({ selectedLegends }); + } else { + this.setState({ selectedLegends: selectedLegends.slice(-1) }); + } + if (this.props.legendProps?.onChange) { + this.props.legendProps.onChange(selectedLegends, event, currentLegend); + } + } + private _getAxisData = (yAxisData: IAxisData) => { if (yAxisData && yAxisData.yAxisDomainValues.length) { const { yAxisDomainValues: domainValue } = yAxisData; @@ -1164,20 +1171,25 @@ export class VerticalBarChartBase * 1. selection: if the user clicks on it * 2. hovering: if there is no selected legend and the user hovers over it */ - private _legendHighlighted = (legendTitle: string) => { - return ( - this.state.selectedLegend === legendTitle || - (this.state.selectedLegend === '' && this.state.activeLegend === legendTitle) - ); + private _legendHighlighted = (legendTitle: string | undefined) => { + return this._getHighlightedLegend().includes(legendTitle!); }; /** * This function checks if none of the legends is selected or hovered. */ private _noLegendHighlighted = () => { - return this.state.selectedLegend === '' && this.state.activeLegend === ''; + return this._getHighlightedLegend().length === 0; }; + private _getHighlightedLegend() { + return this.state.selectedLegends.length > 0 + ? this.state.selectedLegends + : this.state.activeLegend + ? [this.state.activeLegend] + : []; + } + private _getAriaLabel = (point: IVerticalBarChartDataPoint): string => { const xValue = point.xAxisCalloutData ? point.xAxisCalloutData diff --git a/packages/charts/react-charting/src/components/VerticalBarChart/VerticalBarChartRTL.test.tsx b/packages/charts/react-charting/src/components/VerticalBarChart/VerticalBarChartRTL.test.tsx index c677c11883ab44..7a4c6180deeaf8 100644 --- a/packages/charts/react-charting/src/components/VerticalBarChart/VerticalBarChartRTL.test.tsx +++ b/packages/charts/react-charting/src/components/VerticalBarChart/VerticalBarChartRTL.test.tsx @@ -652,6 +652,87 @@ describe('Vertical bar chart - Subcomponent Legends', () => { expect(bars[7]).toHaveStyle('opacity: 0.1'); }, ); + + testWithWait( + 'Should reduce the opacity of the other bars/lines and their legends on mouse over multiple legends', + VerticalBarChart, + { data: pointsWithLine, lineLegendText: 'just line', legendProps: { canSelectMultipleLegends: true } }, + container => { + const bars = getById(container, /_VBC_bar/i); + const line = getById(container, /_VBC_line/i)[0]; + const legends = screen.getAllByText((content, element) => element!.tagName.toLowerCase() === 'button'); + expect(line).toBeDefined(); + expect(bars).toHaveLength(8); + expect(legends).toHaveLength(9); + fireEvent.click(screen.getByText('just line')); + fireEvent.click(screen.getByText('Oranges')); + expect(line.getAttribute('opacity')).toEqual('1'); + expect(screen.getByText('Oranges')).not.toHaveAttribute('opacity'); + expect(screen.getByText('Dogs')).toHaveStyle('opacity: 0.67'); + expect(screen.getByText('Apples')).toHaveStyle('opacity: 0.67'); + expect(screen.getByText('Bananas')).toHaveStyle('opacity: 0.67'); + expect(screen.getByText('Giraffes')).toHaveStyle('opacity: 0.67'); + expect(screen.getByText('Cats')).toHaveStyle('opacity: 0.67'); + expect(screen.getByText('Elephants')).toHaveStyle('opacity: 0.67'); + expect(screen.getByText('Monkeys')).toHaveStyle('opacity: 0.67'); + expect(line).toBeDefined(); + expect(bars[0]).toBeDefined(); + expect(bars[0]).not.toHaveAttribute('opacity'); + expect(bars[1]).toBeDefined(); + expect(bars[1]).toHaveStyle('opacity: 0.1'); + expect(bars[2]).toBeDefined(); + expect(bars[2]).toHaveStyle('opacity: 0.1'); + expect(bars[3]).toBeDefined(); + expect(bars[3]).toHaveStyle('opacity: 0.1'); + expect(bars[4]).toBeDefined(); + expect(bars[4]).toHaveStyle('opacity: 0.1'); + expect(bars[5]).toBeDefined(); + expect(bars[5]).toHaveStyle('opacity: 0.1'); + expect(bars[6]).toBeDefined(); + expect(bars[6]).toHaveStyle('opacity: 0.1'); + expect(bars[7]).toBeDefined(); + expect(bars[7]).toHaveStyle('opacity: 0.1'); + }, + ); + + testWithWait( + 'Should reduce the opacity of the other bars/lines and their legends on mouse over multiple legends', + VerticalBarChart, + { data: pointsWithLine, lineLegendText: 'just line', legendProps: { canSelectMultipleLegends: true } }, + container => { + const bars = getById(container, /_VBC_bar/i); + const line = getById(container, /_VBC_line/i)[0]; + const legends = screen.getAllByText((content, element) => element!.tagName.toLowerCase() === 'button'); + expect(line).toBeDefined(); + expect(bars).toHaveLength(8); + expect(legends).toHaveLength(9); + fireEvent.click(screen.getByText('just line')); + fireEvent.click(screen.getByText('Oranges')); + expect(line.getAttribute('opacity')).toEqual('1'); + expect(screen.getByText('Dogs')).toHaveStyle('opacity: 0.67'); + expect(screen.getByText('Apples')).toHaveStyle('opacity: 0.67'); + expect(screen.getByText('Bananas')).toHaveStyle('opacity: 0.67'); + expect(screen.getByText('Giraffes')).toHaveStyle('opacity: 0.67'); + expect(screen.getByText('Cats')).toHaveStyle('opacity: 0.67'); + expect(screen.getByText('Elephants')).toHaveStyle('opacity: 0.67'); + expect(screen.getByText('Monkeys')).toHaveStyle('opacity: 0.67'); + expect(line).toBeDefined(); + expect(bars[1]).toBeDefined(); + expect(bars[1]).toHaveStyle('opacity: 0.1'); + expect(bars[2]).toBeDefined(); + expect(bars[2]).toHaveStyle('opacity: 0.1'); + expect(bars[3]).toBeDefined(); + expect(bars[3]).toHaveStyle('opacity: 0.1'); + expect(bars[4]).toBeDefined(); + expect(bars[4]).toHaveStyle('opacity: 0.1'); + expect(bars[5]).toBeDefined(); + expect(bars[5]).toHaveStyle('opacity: 0.1'); + expect(bars[6]).toBeDefined(); + expect(bars[6]).toHaveStyle('opacity: 0.1'); + expect(bars[7]).toBeDefined(); + expect(bars[7]).toHaveStyle('opacity: 0.1'); + }, + ); }); describe('Vertical bar chart - Subcomponent callout', () => { diff --git a/packages/react-examples/src/react-charting/VerticalBarChart/VerticalBarChart.Basic.Example.tsx b/packages/react-examples/src/react-charting/VerticalBarChart/VerticalBarChart.Basic.Example.tsx index e9cbcb71d7828a..045c52bbad61a8 100644 --- a/packages/react-examples/src/react-charting/VerticalBarChart/VerticalBarChart.Basic.Example.tsx +++ b/packages/react-examples/src/react-charting/VerticalBarChart/VerticalBarChart.Basic.Example.tsx @@ -23,6 +23,7 @@ interface IVerticalChartState { showAxisTitles: boolean; enableGradient: boolean; roundCorners: boolean; + selectMultipleLegends: boolean; } const options: IChoiceGroupOption[] = [ @@ -42,6 +43,7 @@ export class VerticalBarChartBasicExample extends React.Component, checked: boolean) => { + this.setState({ selectMultipleLegends: checked }); + }; + private _basicExample(): JSX.Element { const points: IVerticalBarChartDataPoint[] = [ { @@ -234,6 +240,13 @@ export class VerticalBarChartBasicExample extends React.Component    +    + {this.state.showAxisTitles && (
@@ -259,6 +272,9 @@ export class VerticalBarChartBasicExample extends React.Component
)} @@ -284,6 +300,9 @@ export class VerticalBarChartBasicExample extends React.Component )}