diff --git a/change/@fluentui-react-charting-3c8f3067-56da-4692-9adf-e2e558d809dc.json b/change/@fluentui-react-charting-3c8f3067-56da-4692-9adf-e2e558d809dc.json new file mode 100644 index 0000000000000..f4b057b2fd92d --- /dev/null +++ b/change/@fluentui-react-charting-3c8f3067-56da-4692-9adf-e2e558d809dc.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Add controlled selection capability to Legends component", + "packageName": "@fluentui/react-charting", + "email": "email not defined", + "dependentChangeType": "patch" +} diff --git a/packages/react-charting/etc/react-charting.api.md b/packages/react-charting/etc/react-charting.api.md index 782f87f357e8b..a0cc968de3e2d 100644 --- a/packages/react-charting/etc/react-charting.api.md +++ b/packages/react-charting/etc/react-charting.api.md @@ -867,6 +867,8 @@ export interface ILegendsProps { onLegendHoverCardLeave?: VoidFunction; overflowProps?: Partial; overflowText?: string; + selectedLegend?: string; + selectedLegends?: string[]; shape?: LegendShape; styles?: IStyleFunctionOrObject; theme?: ITheme; diff --git a/packages/react-charting/src/components/Legends/Legends.base.tsx b/packages/react-charting/src/components/Legends/Legends.base.tsx index 49f0a0f12c60e..5327a371b009d 100644 --- a/packages/react-charting/src/components/Legends/Legends.base.tsx +++ b/packages/react-charting/src/components/Legends/Legends.base.tsx @@ -48,21 +48,48 @@ export class LegendsBase extends React.Component { public constructor(props: ILegendsProps) { super(props); - let defaultSelectedLegends = {}; + + const initialSelectedLegends = props.selectedLegends ?? props.defaultSelectedLegends; + const initialSelectedLegend = props.selectedLegend ?? props.defaultSelectedLegend; + let selectedLegendsState = {}; + if (props.canSelectMultipleLegends) { - defaultSelectedLegends = - props.defaultSelectedLegends?.reduce((combinedDict, key) => ({ [key]: true, ...combinedDict }), {}) || {}; - } else if (props.defaultSelectedLegend) { - defaultSelectedLegends = { [props.defaultSelectedLegend]: true }; + selectedLegendsState = + (initialSelectedLegends ?? [])?.reduce( + (combinedDict, key) => ({ [key]: true, ...combinedDict }), + {}, + ) || {}; + } else if (initialSelectedLegend) { + selectedLegendsState = { [initialSelectedLegend]: true }; } this.state = { activeLegend: '', isHoverCardVisible: false, - selectedLegends: defaultSelectedLegends, + selectedLegends: selectedLegendsState, }; } + public static getDerivedStateFromProps(newProps: ILegendsProps, prevState: ILegendState): ILegendState { + const { selectedLegend, selectedLegends } = newProps; + + if (newProps.canSelectMultipleLegends && selectedLegends !== undefined) { + return { + ...prevState, + selectedLegends: selectedLegends.reduce((combinedDict, key) => ({ [key]: true, ...combinedDict }), {}), + }; + } + + if (!newProps.canSelectMultipleLegends && selectedLegend !== undefined) { + return { + ...prevState, + selectedLegends: { [selectedLegend]: true }, + }; + } + + return prevState; + } + public render(): JSX.Element { const { theme, className, styles } = this.props; this._classNames = getClassNames(styles!, { @@ -164,54 +191,60 @@ export class LegendsBase extends React.Component { }; /** - * This function will get called when there is an ability to - * select multiple legends - * @param legend ILegend + * Determine whether the component is in "controlled" mode for selections, where the selected legend(s) are + * determined entirely by props passed in from the parent component. */ - private _canSelectMultipleLegends = (legend: ILegend): { [key: string]: boolean } => { + private _isInControlledMode = (): boolean => { + return this.props.canSelectMultipleLegends + ? this.props.selectedLegends !== undefined + : this.props.selectedLegend !== undefined; + }; + + /** + * Get the new selected legends based on the legend that was clicked when multi-select is enabled. + * @param legend The legend that was clicked + * @returns An object with the new selected legend(s) state data. + */ + private _getNewSelectedLegendsForMultiselect = (legend: ILegend): { [key: string]: boolean } => { let selectedLegends = { ...this.state.selectedLegends }; + if (selectedLegends[legend.title]) { // Delete entry for the deselected legend to make // the number of keys equal to the number of selected legends delete selectedLegends[legend.title]; } else { selectedLegends[legend.title] = true; + // Clear set if all legends are selected if (Object.keys(selectedLegends).length === this.props.legends.length) { selectedLegends = {}; } } - this.setState({ selectedLegends }); + return selectedLegends; }; /** - * This function will get called when there is - * ability to select only single legend - * @param legend ILegend + * Get the new selected legends based on the legend that was clicked when single-select is enabled. + * @param legend The legend that was clicked + * @returns An object with the new selected legend state data. */ - - private _canSelectOnlySingleLegend = (legend: ILegend): boolean => { - if (this.state.selectedLegends[legend.title]) { - this.setState({ selectedLegends: {} }); - return false; - } else { - this.setState({ selectedLegends: { [legend.title]: true } }); - return true; - } + private _getNewSelectedLegendsForSingleSelect = (legend: ILegend): { [key: string]: boolean } => { + return this.state.selectedLegends[legend.title] ? {} : { [legend.title]: true }; }; private _onClick = (legend: ILegend, event: React.MouseEvent): void => { const { canSelectMultipleLegends = false } = this.props; - let selectedLegends: string[] = []; - if (canSelectMultipleLegends) { - const nextSelectedLegends = this._canSelectMultipleLegends(legend); - selectedLegends = Object.keys(nextSelectedLegends); - } else { - const isSelected = this._canSelectOnlySingleLegend(legend); - selectedLegends = isSelected ? [legend.title] : []; + + const nextSelectedLegends = canSelectMultipleLegends + ? this._getNewSelectedLegendsForMultiselect(legend) + : this._getNewSelectedLegendsForSingleSelect(legend); + + if (!this._isInControlledMode()) { + this.setState({ selectedLegends: nextSelectedLegends }); } - this.props.onChange?.(selectedLegends, event, legend); + + this.props.onChange?.(Object.keys(nextSelectedLegends), event, legend); legend.action?.(); }; diff --git a/packages/react-charting/src/components/Legends/Legends.test.tsx b/packages/react-charting/src/components/Legends/Legends.test.tsx index f5d1a23be67f3..18636e36e3d41 100644 --- a/packages/react-charting/src/components/Legends/Legends.test.tsx +++ b/packages/react-charting/src/components/Legends/Legends.test.tsx @@ -234,3 +234,27 @@ describe('Legends - multi Legends', () => { expect(renderedLegends?.length).toBe(2); }); }); + +describe('Legends - controlled legend selection', () => { + beforeEach(sharedBeforeEach); + afterEach(sharedAfterEach); + it('follows updates in the selectedLegends prop', () => { + wrapper = mount(); + let renderedLegends = wrapper.getDOMNode().querySelectorAll('button[aria-selected="true"]'); + expect(renderedLegends?.length).toBe(1); + + wrapper.setProps({ selectedLegends: [legends[1].title, legends[2].title] }); + renderedLegends = wrapper.getDOMNode().querySelectorAll('button[aria-selected="true"]'); + expect(renderedLegends?.length).toBe(2); + }); + + it('follows updates in the selectedLegend prop', () => { + wrapper = mount(); + let renderedLegends = wrapper.getDOMNode().querySelectorAll('button[aria-selected="true"]'); + expect(renderedLegends?.length).toBe(1); + + wrapper.setProps({ selectedLegends: [legends[1].title] }); + renderedLegends = wrapper.getDOMNode().querySelectorAll('button[aria-selected="true"]'); + expect(renderedLegends?.length).toBe(1); + }); +}); diff --git a/packages/react-charting/src/components/Legends/Legends.types.ts b/packages/react-charting/src/components/Legends/Legends.types.ts index e72c320799930..15ce0eebacda6 100644 --- a/packages/react-charting/src/components/Legends/Legends.types.ts +++ b/packages/react-charting/src/components/Legends/Legends.types.ts @@ -226,18 +226,55 @@ export interface ILegendsProps { onChange?: (selectedLegends: string[], event: React.MouseEvent, currentLegend?: ILegend) => void; /** - * Keys (title) that will be initially used to set selected items. - * This prop is used for multiSelect scenarios. - * In other cases, defaultSelectedLegend should be used. + * Keys (title) that will be initially used to set selected items. This prop is used for multi-select scenarios when + * canSelectMultipleLegends is true; for single-select, use defaultSelectedLegend. + * + * Updating this prop does not change the selection after the component has been initialized. For controlled + * selections, use selectedLegends instead. + * + * @see selectedLegends for setting the selected legends in controlled mode. + * @see defaultSelectedLegend for setting the initially selected legend when canSelectMultipleLegends is false. */ defaultSelectedLegends?: string[]; /** - * Key that will be initially used to set selected item. - * This prop is used for singleSelect scenarios. + * Key that will be initially used to set selected item. This prop is used for single-select scenarios when + * canSelectMultipleLegends is false or unspecified; for multi-select, use defaultSelectedLegends. + * + * Updating this prop does not change the selection after the component has been initialized. For controlled + * selections, use selectedLegend instead. + * + * @see selectedLegend for setting the selected legend in controlled mode. + * @see defaultSelectedLegends for setting the initially selected legends when canSelectMultipleLegends is true. */ defaultSelectedLegend?: string; + /** + * Keys (title) that will be used to set selected items in multi-select scenarios when canSelectMultipleLegends is + * true. For single-select, use selectedLegend. + * + * When this prop is provided, the component is controlled and does not automatically update the selection based on + * user interactions; the parent component must update the value passed to this property by handling the onChange + * event. + * + * @see defaultSelectedLegends for setting the initially-selected legends in uncontrolled mode. + * @see selectedLegend for setting the selected legend when `canSelectMultipleLegends` is `false`. + */ + selectedLegends?: string[]; + + /** + * Key (title) that will be used to set the selected item in single-select scenarios when canSelectMultipleLegends is + * false or unspecified. For multi-select, use selectedLegends. + * + * When this prop is provided, the component is controlled and does not automatically update the selection based on + * user interactions; the parent component must update the value passed to this property by handling the onChange + * event. + * + * @see defaultSelectedLegend for setting the initially-selected legend in uncontrolled mode. + * @see selectedLegends for setting the selected legends when `canSelectMultipleLegends` is `true`. + */ + selectedLegend?: string; + /** * The shape for the legend. */ diff --git a/packages/react-examples/src/react-charting/Legends/Legends.Controlled.Example.tsx b/packages/react-examples/src/react-charting/Legends/Legends.Controlled.Example.tsx new file mode 100644 index 0000000000000..8a02701560f44 --- /dev/null +++ b/packages/react-examples/src/react-charting/Legends/Legends.Controlled.Example.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { Legends, ILegend, DataVizPalette, getColorFromToken } from '@fluentui/react-charting'; +import { Button, Stack } from '@fluentui/react'; + +const legends: ILegend[] = [ + { + title: 'Legend 1', + color: getColorFromToken(DataVizPalette.color1), + }, + { + title: 'Legend 2', + color: getColorFromToken(DataVizPalette.color2), + }, + { + title: 'Legend 3', + color: getColorFromToken(DataVizPalette.color3), + shape: 'diamond', + }, + { + title: 'Legend 4', + color: getColorFromToken(DataVizPalette.color4), + shape: 'triangle', + }, +]; + +export const LegendsControlledExample: React.FunctionComponent = () => { + const [selectedLegends, setSelectedLegends] = React.useState([]); + + const onChange = (keys: string[]) => { + setSelectedLegends(keys); + }; + + return ( +
+ + + + + + + Selected legends: {selectedLegends.join(', ')} +
+ ); +}; diff --git a/packages/react-examples/src/react-charting/Legends/Legends.doc.tsx b/packages/react-examples/src/react-charting/Legends/Legends.doc.tsx index 807597849769d..49297923eaab6 100644 --- a/packages/react-examples/src/react-charting/Legends/Legends.doc.tsx +++ b/packages/react-examples/src/react-charting/Legends/Legends.doc.tsx @@ -6,6 +6,7 @@ import { LegendOverflowExample } from './Legends.Overflow.Example'; import { LegendBasicExample } from './Legends.Basic.Example'; import { LegendWrapLinesExample } from './Legends.WrapLines.Example'; import { LegendStyledExample } from './Legends.Styled.Example'; +import { LegendsControlledExample } from './Legends.Controlled.Example'; const LegendsOverflowExampleCode = require('!raw-loader?esModule=false!@fluentui/react-examples/src/react-charting/Legends/Legends.Overflow.Example.tsx') as string; @@ -15,6 +16,8 @@ const LegendsBasicExampleCode = require('!raw-loader?esModule=false!@fluentui/react-examples/src/react-charting/Legends/Legends.Basic.Example.tsx') as string; const LegendsStyledExampleCode = require('!raw-loader?esModule=false!@fluentui/react-examples/src/react-charting/Legends/Legends.Styled.Example.tsx') as string; +const LegendsControlledExampleCode = + require('!raw-loader?esModule=false!@fluentui/react-examples/src/react-charting/Legends/Legends.Controlled.Example.tsx') as string; export const LegendsPageProps: IDocPageProps = { title: 'Legends', @@ -41,6 +44,11 @@ export const LegendsPageProps: IDocPageProps = { code: LegendsStyledExampleCode, view: , }, + { + title: 'Legend controlled selection', + code: LegendsControlledExampleCode, + view: , + }, ], overview: require('!raw-loader?esModule=false!@fluentui/react-examples/src/react-charting/Legends/docs/LegendsOverview.md'), bestPractices: require('!raw-loader?esModule=false!@fluentui/react-examples/src/react-charting/Legends/docs/LegendsBestPractices.md'), diff --git a/packages/react-examples/src/react-charting/Legends/LegendsPage.tsx b/packages/react-examples/src/react-charting/Legends/LegendsPage.tsx index 6b10896df2a18..d4cbe324a27b8 100644 --- a/packages/react-examples/src/react-charting/Legends/LegendsPage.tsx +++ b/packages/react-examples/src/react-charting/Legends/LegendsPage.tsx @@ -13,6 +13,7 @@ import { LegendBasicExample } from './Legends.Basic.Example'; import { LegendWrapLinesExample } from './Legends.WrapLines.Example'; import { LegendStyledExample } from './Legends.Styled.Example'; import { LegendsOnChangeExample } from './Legends.OnChange.Example'; +import { LegendsControlledExample } from './Legends.Controlled.Example'; const LegendsOverflowExampleCode = require('!raw-loader?esModule=false!@fluentui/react-examples/src/react-charting/Legends/Legends.Overflow.Example.tsx') as string; @@ -24,6 +25,8 @@ const LegendsStyledExampleCode = require('!raw-loader?esModule=false!@fluentui/react-examples/src/react-charting/Legends/Legends.Styled.Example.tsx') as string; const LegendsOnChangeExampleCode = require('!raw-loader?esModule=false!@fluentui/react-examples/src/react-charting/Legends/Legends.OnChange.Example.tsx') as string; +const LegendsControlledExampleCode = + require('!raw-loader?esModule=false!@fluentui/react-examples/src/react-charting/Legends/Legends.Controlled.Example.tsx') as string; export class LegendsPage extends React.Component { public render(): JSX.Element { @@ -52,6 +55,10 @@ export class LegendsPage extends React.Component { + + + + } propertiesTables={ diff --git a/packages/react-examples/src/react-charting/Legends/index.stories.tsx b/packages/react-examples/src/react-charting/Legends/index.stories.tsx index d8d3d731b3b3c..0dc6d687b0794 100644 --- a/packages/react-examples/src/react-charting/Legends/index.stories.tsx +++ b/packages/react-examples/src/react-charting/Legends/index.stories.tsx @@ -5,6 +5,7 @@ import { LegendsOnChangeExample } from './Legends.OnChange.Example'; import { LegendOverflowExample } from './Legends.Overflow.Example'; import { LegendStyledExample } from './Legends.Styled.Example'; import { LegendWrapLinesExample } from './Legends.WrapLines.Example'; +import { LegendsControlledExample } from './Legends.Controlled.Example'; export const Basic = () => ; @@ -16,6 +17,8 @@ export const Styled = () => ; export const WrapLines = () => ; +export const Controlled = () => ; + export default { title: 'Components/Legends', };