From fa08fec97a5bc070e2866633c792f7066f0c86e8 Mon Sep 17 00:00:00 2001 From: srmukher <120183316+srmukher@users.noreply.github.com> Date: Tue, 24 Dec 2024 22:57:46 +0530 Subject: [PATCH] [Donut] Legends multi selection for Donut Charts (#33447) --- ...-57a1c519-224f-4da6-bdd9-bfc7decab0f7.json | 7 ++ .../DeclarativeChart/DeclarativeChart.tsx | 2 +- .../src/components/DonutChart/Arc/Arc.tsx | 20 +++-- .../components/DonutChart/Arc/Arc.types.ts | 2 +- .../components/DonutChart/DonutChart.base.tsx | 75 +++++++++++++------ .../DonutChart/DonutChartRTL.test.tsx | 40 +++++++++- .../components/DonutChart/Pie/Pie.types.ts | 2 +- .../DonutChart/DonutChart.Basic.Example.tsx | 75 ++++++++++++++++++- 8 files changed, 183 insertions(+), 40 deletions(-) create mode 100644 change/@fluentui-react-charting-57a1c519-224f-4da6-bdd9-bfc7decab0f7.json diff --git a/change/@fluentui-react-charting-57a1c519-224f-4da6-bdd9-bfc7decab0f7.json b/change/@fluentui-react-charting-57a1c519-224f-4da6-bdd9-bfc7decab0f7.json new file mode 100644 index 0000000000000..79123ffb677ff --- /dev/null +++ b/change/@fluentui-react-charting-57a1c519-224f-4da6-bdd9-bfc7decab0f7.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Legends multi selection for Donut Charts", + "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 9929b993f178a..5ee0c2e35ebb3 100644 --- a/packages/charts/react-charting/src/components/DeclarativeChart/DeclarativeChart.tsx +++ b/packages/charts/react-charting/src/components/DeclarativeChart/DeclarativeChart.tsx @@ -129,7 +129,7 @@ export const DeclarativeChart: React.FunctionComponent = return ( ); diff --git a/packages/charts/react-charting/src/components/DonutChart/Arc/Arc.tsx b/packages/charts/react-charting/src/components/DonutChart/Arc/Arc.tsx index 7d7baef968045..ff1b9a261948b 100644 --- a/packages/charts/react-charting/src/components/DonutChart/Arc/Arc.tsx +++ b/packages/charts/react-charting/src/components/DonutChart/Arc/Arc.tsx @@ -31,12 +31,11 @@ export class Arc extends React.Component { } public render(): JSX.Element { - const { arc, href, focusedArcId } = this.props; + const { arc, href, focusedArcId, activeArc } = this.props; const getClassNames = classNamesFunction(); const id = this.props.uniqText! + this.props.data!.data.legend!.replace(/\s+/, '') + this.props.data!.data.data; const opacity: number = - this.props.activeArc === this.props.data!.data.legend || this.props.activeArc === '' ? 1 : 0.1; - + activeArc && activeArc.length > 0 ? (activeArc.includes(this.props.data?.data.legend!) ? 1 : 0.1) : 1; const startAngle = this.props.data?.startAngle ?? 0; const endAngle = (this.props.data?.endAngle ?? 0) - startAngle; const cornerRadius = this.props.roundCorners ? 3 : 0; @@ -70,7 +69,9 @@ export class Arc extends React.Component { d={arc.cornerRadius(cornerRadius)(this.props.data)} onFocus={this._onFocus.bind(this, this.props.data!.data, id)} className={classNames.root} - data-is-focusable={this.props.activeArc === this.props.data!.data.legend || this.props.activeArc === ''} + data-is-focusable={ + this._shouldHighlightArc(this.props.data!.data.legend!) || this.props.activeArc?.length === 0 + } onMouseOver={this._hoverOn.bind(this, this.props.data!.data)} onMouseMove={this._hoverOn.bind(this, this.props.data!.data)} onMouseLeave={this._hoverOff} @@ -123,13 +124,18 @@ export class Arc extends React.Component { return point.callOutAccessibilityData?.ariaLabel || (legend ? `${legend}, ` : '') + `${yValue}.`; }; - private _renderArcLabel = (className: string) => { - const { arc, data, innerRadius, outerRadius, showLabelsInPercent, totalValue, hideLabels, activeArc } = this.props; + private _shouldHighlightArc = (legend?: string): boolean => { + const { activeArc } = this.props; + // If no activeArc is provided, highlight all arcs. Otherwise, only highlight the arcs that are active. + return !activeArc || activeArc.length === 0 || legend === undefined || activeArc.includes(legend); + }; + private _renderArcLabel = (className: string) => { + const { arc, data, innerRadius, outerRadius, showLabelsInPercent, totalValue, hideLabels } = this.props; if ( hideLabels || Math.abs(data!.endAngle - data!.startAngle) < Math.PI / 12 || - (activeArc !== data!.data.legend && activeArc !== '') + !this._shouldHighlightArc(data!.data.legend!) ) { return null; } diff --git a/packages/charts/react-charting/src/components/DonutChart/Arc/Arc.types.ts b/packages/charts/react-charting/src/components/DonutChart/Arc/Arc.types.ts index 6de0cdd0afc77..6bff5071cd7e6 100644 --- a/packages/charts/react-charting/src/components/DonutChart/Arc/Arc.types.ts +++ b/packages/charts/react-charting/src/components/DonutChart/Arc/Arc.types.ts @@ -73,7 +73,7 @@ export interface IArcProps { /** * Active Arc for chart */ - activeArc?: string; + activeArc?: string[]; /** * internal prop for href diff --git a/packages/charts/react-charting/src/components/DonutChart/DonutChart.base.tsx b/packages/charts/react-charting/src/components/DonutChart/DonutChart.base.tsx index ff04507c955bb..fb747b57d5fef 100644 --- a/packages/charts/react-charting/src/components/DonutChart/DonutChart.base.tsx +++ b/packages/charts/react-charting/src/components/DonutChart/DonutChart.base.tsx @@ -25,9 +25,9 @@ export interface IDonutChartState { xCalloutValue?: string; yCalloutValue?: string; focusedArcId?: string; - selectedLegend: string; dataPointCalloutProps?: IChartDataPoint; callOutAccessibilityData?: IAccessibilityProps; + selectedLegends: string[]; } export class DonutChartBase extends React.Component implements IChart { @@ -73,12 +73,12 @@ export class DonutChartBase extends React.Component d.data! >= 0)); const valueInsideDonut = this._valueInsideDonut(this.props.valueInsideDonut!, chartData!); - return !this._isChartEmpty() ? (
{ - if (this.state.selectedLegend === point.legend) { - this.setState({ selectedLegend: '' }); - } else { - this.setState({ selectedLegend: point.legend! }); - } - }, hoverAction: () => { this._handleChartMouseLeave(); this.setState({ activeLegend: point.legend! }); }, onMouseOutAction: () => { - this.setState({ activeLegend: '' }); + this.setState({ activeLegend: undefined }); }, }; return legend; }); + const legends = ( ); return legends; } + private _onLegendSelectionChange( + selectedLegends: string[], + event: React.MouseEvent, + currentLegend?: ILegend, + ): void { + if (this.props.legendProps && 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 _focusCallback = (data: IChartDataPoint, id: string, element: SVGPathElement): void => { this._currentHoverElement = element; this.setState({ /** Show the callout if highlighted arc is focused and Hide it if unhighlighted arc is focused */ - showHover: this.state.selectedLegend === '' || this.state.selectedLegend === data.legend, + showHover: this._noLegendsHighlighted() || this._isLegendHighlighted(data.legend), value: data.data!.toString(), legend: data.legend, color: data.color!, @@ -307,7 +317,7 @@ export class DonutChartBase extends React.Component { - if (point.legend === highlightedLegend || (this.state.showHover && point.legend === this.state.legend)) { - legendValue = point.yAxisCalloutData ? point.yAxisCalloutData : point.data!; + const highlightedLegends = this._getHighlightedLegend(); + if (valueInsideDonut !== undefined && (highlightedLegends.length === 1 || this.state.showHover)) { + const pointValue = data.find(point => this._isLegendHighlighted(point.legend)); + return pointValue + ? pointValue.yAxisCalloutData + ? pointValue.yAxisCalloutData + : pointValue.data! + : valueInsideDonut; + } else if (highlightedLegends.length > 0) { + let totalValue = 0; + data.forEach(point => { + if (highlightedLegends.includes(point.legend!)) { + totalValue += point.data!; } - return; }); - return legendValue; + return totalValue; } else { return valueInsideDonut; } @@ -359,12 +375,23 @@ export class DonutChartBase extends React.Component 0 + ? this.state.selectedLegends + : this.state.activeLegend + ? [this.state.activeLegend] + : []; } + private _isLegendHighlighted = (legend: string | undefined): boolean => { + return this._getHighlightedLegend().includes(legend!); + }; + + private _noLegendsHighlighted = (): boolean => { + return this._getHighlightedLegend().length === 0; + }; + private _isChartEmpty(): boolean { return !( this.props.data && diff --git a/packages/charts/react-charting/src/components/DonutChart/DonutChartRTL.test.tsx b/packages/charts/react-charting/src/components/DonutChart/DonutChartRTL.test.tsx index db7dcb00b7e58..16bb8f56bc206 100644 --- a/packages/charts/react-charting/src/components/DonutChart/DonutChartRTL.test.tsx +++ b/packages/charts/react-charting/src/components/DonutChart/DonutChartRTL.test.tsx @@ -18,6 +18,9 @@ describe('Donut chart interactions', () => { beforeEach(() => { sharedBeforeEach(); jest.spyOn(global.Math, 'random').mockReturnValue(0.1); + // Mock the implementation of wrapTextInsideDonut as it internally calls a Browser Function like + // getComputedTextLength() which will otherwise lead to a crash if mounted + jest.spyOn(utils, 'wrapTextInsideDonut').mockImplementation(() => '20000'); }); afterEach(() => { jest.spyOn(global.Math, 'random').mockRestore(); @@ -70,7 +73,7 @@ describe('Donut chart interactions', () => { test('Should highlight the corresponding Pie on mouse over on legends', () => { // Arrange - const { container } = render(); + const { container } = render(); // Act const legend = screen.queryByText('first'); @@ -157,9 +160,6 @@ describe('Donut chart interactions', () => { }); test('Should change value inside donut with the legend value on mouseOver legend ', () => { - // Mock the implementation of wrapTextInsideDonut as it internally calls a Browser Function like - // getComputedTextLength() which will otherwise lead to a crash if mounted - jest.spyOn(utils, 'wrapTextInsideDonut').mockImplementation(() => '1000'); // Arrange const { container } = render( , @@ -184,6 +184,38 @@ describe('Donut chart interactions', () => { // Assert expect(container).toMatchSnapshot(); }); + + // add test for legend multi select + test('Should select multiple legends on click', () => { + // Arrange + const { container } = render( + , + ); + + // Act + const firstLegend = screen.queryByText('first')?.closest('button'); + const secondLegend = screen.queryByText('second')?.closest('button'); + expect(firstLegend).toBeDefined(); + expect(secondLegend).toBeDefined(); + fireEvent.click(firstLegend!); + fireEvent.click(secondLegend!); + + // Assert + expect(firstLegend).toHaveAttribute('aria-selected', 'true'); + expect(secondLegend).toHaveAttribute('aria-selected', 'true'); + + const getById = queryAllByAttribute.bind(null, 'id'); + expect(getById(container, /Pie.*?first/i)[0]).toHaveStyle('opacity: 1.0'); + expect(getById(container, /Pie.*?second/i)[0]).toHaveStyle('opacity: 1.0'); + expect(getById(container, /Pie.*?third/i)[0]).toHaveStyle('opacity: 0.1'); + }); }); describe('Donut Chart - axe-core', () => { diff --git a/packages/charts/react-charting/src/components/DonutChart/Pie/Pie.types.ts b/packages/charts/react-charting/src/components/DonutChart/Pie/Pie.types.ts index aa9ca9b2dc44a..f5629edf8aede 100644 --- a/packages/charts/react-charting/src/components/DonutChart/Pie/Pie.types.ts +++ b/packages/charts/react-charting/src/components/DonutChart/Pie/Pie.types.ts @@ -54,7 +54,7 @@ export interface IPieProps { /** * Active Arc for chart */ - activeArc?: string; + activeArc?: string[]; /** * string for callout id diff --git a/packages/react-examples/src/react-charting/DonutChart/DonutChart.Basic.Example.tsx b/packages/react-examples/src/react-charting/DonutChart/DonutChart.Basic.Example.tsx index 71be65bd695da..ac96c767bef0e 100644 --- a/packages/react-examples/src/react-charting/DonutChart/DonutChart.Basic.Example.tsx +++ b/packages/react-examples/src/react-charting/DonutChart/DonutChart.Basic.Example.tsx @@ -14,6 +14,7 @@ import { Toggle } from '@fluentui/react/lib/Toggle'; interface IDonutChartState { enableGradient: boolean; roundCorners: boolean; + legendMultiSelect: boolean; } export class DonutChartBasicExample extends React.Component { @@ -22,6 +23,7 @@ export class DonutChartBasicExample extends React.Component +    +
); @@ -92,4 +159,8 @@ export class DonutChartBasicExample extends React.Component, checked: boolean) => { this.setState({ roundCorners: checked }); }; + + private _onToggleLegendMultiSelect = (ev: React.MouseEvent, checked: boolean) => { + this.setState({ legendMultiSelect: checked }); + }; }