Repository: recharts/recharts (TypeScript)

Issue:
Tooltip and Legend payload sorting
In Recharts 2.x, Legend and Tooltip values are sorted by order of components in DOM. Once we solve #2788, we are no longer going to have access to the DOM element order (because there might be components inside of components inside of components and we do not want to parse the whole world).

Right now, in 3.x branch, the Legend and Tooltip values arbitrarily swap, based on how React decides to render things. Sometimes that happens on mouse hover and it's quite horrible frankly.

We're going to need to:
1. Decide on some default sort that will replace DOM element order
2. Add new props to Tooltip and Legend to allow overwriting the default sort


Discussion:
Has anyone thought about what the interface for sorting labels will be? I ran into this issue while testing 3.x. Currently handling it by using custom content components.

`labelSorter` prop added to Tooltip and Legend content components:
```
labelSorter={(payloadItem) => {
  return dataKeyList.indexOf(item.dataKey)
}}
```


In content components:
```
return payload.sort((a, b) => labelSorter(a) - labelSorter(b))
```
I don't think anyone has thought of anything quite yet but good to bring this up.

I think your idea is good, but we might want to give the user a little more flexibility. Like passing the comparison function itself.
In the legend we would do:

`payload.sort(sortingComparitor)`

and the user would do something like

`legendSortingComparitor={(a, b) => labelSorter(a) - labelSorter(b)}`

`legendSortingComparitor` is a bit long winded though 😅. Maybe `legendSorter` or `payloadSorter`

Tooltip has `itemSorter` which we could be consistent with
We could make it accept a `DataKey` prop so that it's easy to pass in a single string value. Something like

```
<Legend sortBy="name" />
```
Ha but the Tooltip.itemSorter is a good point. We could do the same for Legend. Or even make it so that the Legend uses Tooltip sorter, if one is provided but the other one is not?

Aaaand add a default itemSorter so that the components are not jumping around by default.
Yeah that sounds good to me



Patch:
diff --git a/src/component/DefaultLegendContent.tsx b/src/component/DefaultLegendContent.tsx
index f9746109ad..4f3b416c08 100644
--- a/src/component/DefaultLegendContent.tsx
+++ b/src/component/DefaultLegendContent.tsx
@@ -4,6 +4,7 @@
 import React, { PureComponent, ReactNode, MouseEvent, ReactElement } from 'react';
 
 import clsx from 'clsx';
+import sortBy from 'lodash/sortBy';
 import { Surface } from '../container/Surface';
 import { Symbols } from '../shape/Symbols';
 import {
@@ -55,6 +56,7 @@ interface InternalProps {
    * A custom payload can be passed here if desired or it can be passed from the Legend "content" callback.
    */
   payload?: ReadonlyArray<LegendPayload>;
+  itemSorter?: 'value' | 'dataKey' | ((item: LegendPayload) => number | string);
 }
 
 export type Props = InternalProps & Omit<PresentationAttributesAdaptChildEvent<any, ReactElement>, keyof InternalProps>;
@@ -62,12 +64,13 @@ export type Props = InternalProps & Omit<PresentationAttributesAdaptChildEvent<a
 export class DefaultLegendContent extends PureComponent<Props> {
   static displayName = 'Legend';
 
-  static defaultProps = {
+  static defaultProps: Partial<Props> = {
+    align: 'center',
     iconSize: 14,
+    inactiveColor: '#ccc',
+    itemSorter: 'value',
     layout: 'horizontal',
-    align: 'center',
     verticalAlign: 'middle',
-    inactiveColor: '#ccc',
   };
 
   /**
@@ -140,7 +143,7 @@ export class DefaultLegendContent extends PureComponent<Props> {
    * @return Items
    */
   renderItems() {
-    const { payload, iconSize, layout, formatter, inactiveColor, iconType } = this.props;
+    const { payload, iconSize, layout, formatter, inactiveColor, iconType, itemSorter } = this.props;
     const viewBox = { x: 0, y: 0, width: SIZE, height: SIZE };
     const itemStyle = {
       display: layout === 'horizontal' ? 'inline-block' : 'block',
@@ -148,7 +151,7 @@ export class DefaultLegendContent extends PureComponent<Props> {
     };
     const svgStyle = { display: 'inline-block', verticalAlign: 'middle', marginRight: 4 };
 
-    return payload.map((entry, i) => {
+    return (itemSorter ? sortBy(payload, itemSorter) : payload).map((entry, i) => {
       const finalFormatter = entry.formatter || formatter;
       const className = clsx({
         'recharts-legend-item': true,
diff --git a/src/component/Legend.tsx b/src/component/Legend.tsx
index 2d15b600e2..87c486f1c2 100644
--- a/src/component/Legend.tsx
+++ b/src/component/Legend.tsx
@@ -175,9 +175,9 @@ export class Legend extends PureComponent<Props, State> {
   static displayName = 'Legend';
 
   static defaultProps = {
+    align: 'center',
     iconSize: 14,
     layout: 'horizontal',
-    align: 'center',
     verticalAlign: 'bottom',
   };
 
diff --git a/src/state/selectors/legendSelectors.ts b/src/state/selectors/legendSelectors.ts
index 51b23c9240..0b6abbbb77 100644
--- a/src/state/selectors/legendSelectors.ts
+++ b/src/state/selectors/legendSelectors.ts
@@ -1,5 +1,4 @@
 import { createSelector } from 'reselect';
-import sortBy from 'lodash/sortBy';
 import { RechartsRootState } from '../store';
 import { LegendState } from '../legendSlice';
 import { LegendPayload } from '../../component/DefaultLegendContent';
@@ -11,5 +10,5 @@ const selectAllLegendPayload2DArray = (state: RechartsRootState): ReadonlyArray<
 
 export const selectLegendPayload: (state: RechartsRootState) => ReadonlyArray<LegendPayload> = createSelector(
   [selectAllLegendPayload2DArray],
-  payloads => sortBy(payloads.flat(1), 'value'),
+  payloads => payloads.flat(1),
 );
diff --git a/storybook/stories/API/component/Legend.stories.tsx b/storybook/stories/API/component/Legend.stories.tsx
index ea4ac15873..1d3977161a 100644
--- a/storybook/stories/API/component/Legend.stories.tsx
+++ b/storybook/stories/API/component/Legend.stories.tsx
@@ -28,8 +28,8 @@ export const API = {
         >
           {/* The target component */}
           <Legend {...args} />
-          <Line dataKey="uv" stroke="#8884d8" />
-          <Line dataKey="pv" stroke="#82ca9d" />
+          <Line dataKey="uv" stroke="#8884d8" name="Series 1 (UV)" />
+          <Line dataKey="pv" stroke="#82ca9d" name="Series 2 (PV)" />
         </LineChart>
       </ResponsiveContainer>
     );
diff --git a/storybook/stories/API/props/Legend.ts b/storybook/stories/API/props/Legend.ts
index 8174403ef4..1bae6875ac 100644
--- a/storybook/stories/API/props/Legend.ts
+++ b/storybook/stories/API/props/Legend.ts
@@ -94,6 +94,22 @@ The exact behavior changes depending on \`align\` prop.`,
       category: 'General',
     },
   },
+  itemSorter: {
+    description: `How should Legend items be sorted.
+      Can be either a string (the name of the key to sort by) or a function.
+      The function should return one value to be compared. This sorts always in ascending order.`,
+    defaultValue: 'value',
+    options: ['value', 'dataKey'],
+    control: {
+      type: 'inline-radio',
+    },
+    table: {
+      category: 'Content',
+      type: {
+        summary: 'value | dataKey | Function',
+      },
+    },
+  },
   content: {
     description: `If set to a React element, the option will be used to render the legend.
     If set to a function, the function will be called to render the legend's content.`,


Test patch:
diff --git a/test/component/Legend.spec.tsx b/test/component/Legend.spec.tsx
index e05c531591..888df6248e 100644
--- a/test/component/Legend.spec.tsx
+++ b/test/component/Legend.spec.tsx
@@ -605,6 +605,36 @@ describe('<Legend />', () => {
       ]);
     });
 
+    test('sorts legend items by name by default', () => {
+      const { container } = render(
+        <LineChart width={500} height={500} data={numericalData}>
+          <Legend />
+          <Line dataKey="percent" name="B" />
+          <Line dataKey="value" name="A" />
+        </LineChart>,
+      );
+
+      expectLegendLabels(container, [
+        { textContent: 'A', fill: 'none' },
+        { textContent: 'B', fill: 'none' },
+      ]);
+    });
+
+    test('sorts legend items when itemSorter=dataKey', () => {
+      const { container } = render(
+        <LineChart width={500} height={500} data={numericalData}>
+          <Legend itemSorter="dataKey" />
+          <Line dataKey="percent" name="B" />
+          <Line dataKey="value" name="A" />
+        </LineChart>,
+      );
+
+      expectLegendLabels(container, [
+        { textContent: 'B', fill: 'none' },
+        { textContent: 'A', fill: 'none' },
+      ]);
+    });
+
     test('Legend defaults are read correctly', () => {
       const { container } = render(
         <LineChart width={500} height={500} data={categoricalData}>
@@ -2279,25 +2309,59 @@ describe('<Legend />', () => {
       );
 
       expectLegendLabels(container, [
-        {
-          fill: 'fill1',
-          textContent: 'name1',
-        },
-        {
-          fill: 'fill2',
-          textContent: 'name2',
-        },
-        {
-          fill: 'fill3',
-          textContent: 'name3',
-        },
-        {
-          fill: 'fill4',
-          textContent: 'name4',
-        },
+        { fill: 'fill1', textContent: 'name1' },
+        { fill: 'fill2', textContent: 'name2' },
+        { fill: 'fill3', textContent: 'name3' },
+        { fill: 'fill4', textContent: 'name4' },
       ]);
     });
 
+    describe('itemSorter', () => {
+      it('should sort items by the special name property by default', () => {
+        const dataWithSpecialNameAndFillPropertiesInDifferentOrder = [
+          { name: 'name2', fill: 'fill2', value: 34 },
+          { name: 'name1', fill: 'fill1', value: 12 },
+          { name: 'name4', fill: 'fill4', value: 78 },
+          { name: 'name3', fill: 'fill3', value: 56 },
+        ];
+
+        const { container } = render(
+          <PieChart width={500} height={500}>
+            <Legend />
+            <Pie data={dataWithSpecialNameAndFillPropertiesInDifferentOrder} dataKey="value" />
+          </PieChart>,
+        );
+
+        expectLegendLabels(container, [
+          { fill: 'fill1', textContent: 'name1' },
+          { fill: 'fill2', textContent: 'name2' },
+          { fill: 'fill3', textContent: 'name3' },
+          { fill: 'fill4', textContent: 'name4' },
+        ]);
+      });
+
+      it.each(['dataKey', null] as const)(
+        'should leave items in the original data order when itemSorter=%s',
+        itemSorter => {
+          const { container } = render(
+            <PieChart width={500} height={500}>
+              <Legend itemSorter={itemSorter} />
+              <Pie data={numericalData} dataKey="percent" nameKey="value" />
+            </PieChart>,
+          );
+
+          expectLegendLabels(container, [
+            { fill: '#808080', textContent: 'Luck' },
+            { fill: '#808080', textContent: 'Skill' },
+            { fill: '#808080', textContent: 'Concentrated power of will' },
+            { fill: '#808080', textContent: 'Pleasure' },
+            { fill: '#808080', textContent: 'Pain' },
+            { fill: '#808080', textContent: 'Reason to remember the name' },
+          ]);
+        },
+      );
+    });
+
     it('should disappear after Pie data is removed', () => {
       const { container, rerender } = render(
         <PieChart width={500} height={500}>