diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 91bc703b9de..e21ef14ba3d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -80,6 +80,10 @@ jobs:
- name: Install
run: yarn install --immutable
+ - name: Override react-is for charts
+ if: ${{ matrix.react == '18' && matrix.spec == 'charts' }}
+ run: jq '.resolutions["react-is"] = "18"' package.json > tmp.json && mv tmp.json package.json
+
- name: Install 18
if: ${{ matrix.react == '18' }}
run: |
diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html
index f957ff1189a..79d75f0f94e 100644
--- a/.storybook/preview-head.html
+++ b/.storybook/preview-head.html
@@ -191,6 +191,11 @@
align-items: flex-start;
}
+ /* Stacked chart tooltip total row */
+ .stackedTooltipWithTotal .recharts-tooltip-item:last-child {
+ font-weight: bold;
+ }
+
/* TODO remove this workaround as soon as https://github.com/storybookjs/storybook/issues/20497 is fixed */
.docs-story > div > div[scale] {
min-height: 20px;
diff --git a/cypress/support/utils.tsx b/cypress/support/utils.tsx
index ed23c7538ee..4c35c2ac5f2 100644
--- a/cypress/support/utils.tsx
+++ b/cypress/support/utils.tsx
@@ -100,3 +100,27 @@ export function testChartLegendConfig(Component, props) {
cy.findAllByTestId('catval').should('be.visible');
});
}
+
+export function testStackAggregateTotals(Component, props) {
+ it('showStackAggregateTotals', () => {
+ const { dataset, measures } = props;
+ const stackAccessors = measures.filter((measure) => measure.stackId != null).map((measure) => measure.accessor);
+ const expectedTotals: number[] = dataset.map((entry) =>
+ stackAccessors.reduce((sum, accessor) => sum + (Number(entry[accessor]) || 0), 0),
+ );
+
+ cy.mount();
+
+ expectedTotals.forEach((total) => {
+ cy.get('.recharts-label').contains(total).closest('text').should('have.attr', 'font-weight', 'bold');
+ });
+
+ // tooltip
+ cy.get('.recharts-wrapper').trigger('mousemove', 'center', { force: true });
+ cy.get('.recharts-tooltip-item').last().should('contain.text', 'Total : 560').and('have.css', 'font-weight', '700');
+
+ cy.mount();
+ cy.get('.recharts-bar-rectangles').should('exist');
+ cy.get('text[font-weight="bold"]').should('not.exist');
+ });
+}
diff --git a/packages/charts/src/components/BarChart/BarChart.cy.tsx b/packages/charts/src/components/BarChart/BarChart.cy.tsx
index 1d765973ad5..fb90d53974f 100644
--- a/packages/charts/src/components/BarChart/BarChart.cy.tsx
+++ b/packages/charts/src/components/BarChart/BarChart.cy.tsx
@@ -1,6 +1,11 @@
import { complexDataSet } from '../../resources/DemoProps.js';
import { BarChart } from './index.js';
-import { cypressPassThroughTestsFactory, testChartLegendConfig, testChartZoomingTool } from '@/cypress/support/utils';
+import {
+ cypressPassThroughTestsFactory,
+ testChartLegendConfig,
+ testChartZoomingTool,
+ testStackAggregateTotals,
+} from '@/cypress/support/utils';
const dimensions = [
{
@@ -93,4 +98,13 @@ describe('BarChart', () => {
testChartZoomingTool(BarChart, { dataset: complexDataSet, dimensions, measures });
cypressPassThroughTestsFactory(BarChart, { dimensions: [], measures: [] });
+
+ testStackAggregateTotals(BarChart, {
+ dataset: complexDataSet.slice(0, 3),
+ dimensions,
+ measures: [
+ { accessor: 'users', stackId: 'A', label: 'Users' },
+ { accessor: 'sessions', stackId: 'A', label: 'Active Sessions' },
+ ],
+ });
});
diff --git a/packages/charts/src/components/BarChart/BarChart.mdx b/packages/charts/src/components/BarChart/BarChart.mdx
index 85333181ac9..39f2bbf4aff 100644
--- a/packages/charts/src/components/BarChart/BarChart.mdx
+++ b/packages/charts/src/components/BarChart/BarChart.mdx
@@ -3,6 +3,8 @@ import { Canvas, Meta } from '@storybook/addon-docs/blocks';
import TooltipStory from '../../resources/TooltipConfig.mdx';
import LegendStory from '../../resources/LegendConfig.mdx';
import NormalizedStackedChartStory from '../../resources/NormalizedStackedChart.mdx';
+import StackAggregateTotalsStory from '../../resources/StackAggregateTotals.mdx';
+import CustomTooltipTotalStory from '../../resources/CustomTooltipTotal.mdx';
import * as ComponentStories from './BarChart.stories';
@@ -67,6 +69,16 @@ You can set a reference line to any value by using the `referenceLine` `chartCon
+You can display a total label at the end of each stacked bar group by setting chartConfig.showStackAggregateTotals to true. The tooltip includes the total automatically when only a single bar per dimension is present.>}
+/>
+
+When multiple bars per dimension are present (e.g. stacked + standalone), the built-in tooltip total is not available. You can provide a custom tooltip via the tooltipConfig.content prop to display a total for specific measures.>}
+/>
+
diff --git a/packages/charts/src/components/BarChart/BarChart.stories.tsx b/packages/charts/src/components/BarChart/BarChart.stories.tsx
index ea200bef050..3b86afe7223 100644
--- a/packages/charts/src/components/BarChart/BarChart.stories.tsx
+++ b/packages/charts/src/components/BarChart/BarChart.stories.tsx
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
complexDataSet,
+ CustomTooltipContent,
legendConfig,
secondaryDimensionDataSet,
simpleDataSet,
@@ -165,6 +166,56 @@ export const WithNormalizedStacks: Story = {
args: stackedNormalizedConfig,
};
+export const WithStackAggregateTotalsAndTooltip: Story = {
+ name: 'With Stack Aggregate Totals',
+ args: {
+ dataset: complexDataSet.slice(0, 3),
+ measures: [
+ {
+ accessor: 'users',
+ stackId: 'A',
+ label: 'Users',
+ },
+ {
+ accessor: 'sessions',
+ stackId: 'A',
+ label: 'Active Sessions',
+ },
+ ],
+ chartConfig: {
+ showStackAggregateTotals: true,
+ },
+ },
+};
+
+export const WithCustomTooltipTotal: Story = {
+ args: {
+ dataset: complexDataSet.slice(0, 5),
+ measures: [
+ {
+ accessor: 'users',
+ stackId: 'A',
+ label: 'Users',
+ },
+ {
+ accessor: 'sessions',
+ stackId: 'A',
+ label: 'Active Sessions',
+ },
+ {
+ accessor: 'volume',
+ label: 'Vol.',
+ },
+ ],
+ chartConfig: {
+ showStackAggregateTotals: true,
+ },
+ tooltipConfig: {
+ content: ,
+ },
+ },
+};
+
export const WithCustomTooltipConfig: Story = {
args: tooltipConfig,
};
diff --git a/packages/charts/src/components/BarChart/index.tsx b/packages/charts/src/components/BarChart/index.tsx
index 4bcc5daf426..903ec73d4d8 100644
--- a/packages/charts/src/components/BarChart/index.tsx
+++ b/packages/charts/src/components/BarChart/index.tsx
@@ -33,6 +33,8 @@ import type { IChartMeasure } from '../../interfaces/IChartMeasure.js';
import { ChartContainer } from '../../internal/ChartContainer.js';
import { ChartDataLabel } from '../../internal/ChartDataLabel.js';
import { defaultFormatter } from '../../internal/defaults.js';
+import { StackAggregateLabel } from '../../internal/StackAggregateLabel.js';
+import { StackedTooltipContent } from '../../internal/StackedTooltipContent.js';
import { brushProps, tickLineConfig, tooltipContentStyle, tooltipFillOpacity } from '../../internal/staticProps.js';
import { getCellColors, resolvePrimaryAndSecondaryMeasures } from '../../internal/Utils.js';
import { XAxisTicks } from '../../internal/XAxisTicks.js';
@@ -168,11 +170,12 @@ const BarChart = forwardRef((props, ref) => {
};
const referenceLine = chartConfig.referenceLine;
- const { dimensions, measures } = usePrepareDimensionsAndMeasures(
+ const { dimensions, measures, stackGroups, lastInStack } = usePrepareDimensionsAndMeasures(
props.dimensions,
props.measures,
dimensionDefaults,
measureDefaults,
+ chartConfig.showStackAggregateTotals,
);
const tooltipValueFormatter = useTooltipFormatter(measures);
@@ -224,6 +227,10 @@ const BarChart = forwardRef((props, ref) => {
const { isMounted, handleBarAnimationStart, handleBarAnimationEnd } = useCancelAnimationFallback(noAnimation);
+ const stackGroupKeys = Object.keys(stackGroups);
+ const showStackTotalInTooltip =
+ chartConfig.showStackAggregateTotals && stackGroupKeys.length === 1 && measures.every((m) => m.stackId != null);
+
const { chartConfig: _0, dimensions: _1, measures: _2, ...propsWithoutOmitted } = rest;
return (
((props, ref) => {
valueAccessor={valueAccessor(element.accessor)}
content={}
/>
+ {chartConfig.showStackAggregateTotals &&
+ element.stackId &&
+ typeof element.accessor === 'string' &&
+ lastInStack.has(element.accessor) && (
+ }
+ />
+ )}
{dataset.map((data, i) => {
return (
((props, ref) => {
contentStyle={tooltipContentStyle}
labelFormatter={tooltipLabelFormatter}
{...tooltipConfig}
+ {...(showStackTotalInTooltip && {
+ content: (
+
+ ),
+ })}
/>
)}
{!!chartConfig.zoomingTool && (
diff --git a/packages/charts/src/components/ColumnChart/ColumnChart.cy.tsx b/packages/charts/src/components/ColumnChart/ColumnChart.cy.tsx
index 3e7024fa188..54ec1dcf6c3 100644
--- a/packages/charts/src/components/ColumnChart/ColumnChart.cy.tsx
+++ b/packages/charts/src/components/ColumnChart/ColumnChart.cy.tsx
@@ -1,6 +1,11 @@
import { complexDataSet } from '../../resources/DemoProps.js';
import { ColumnChart } from './index.js';
-import { cypressPassThroughTestsFactory, testChartLegendConfig, testChartZoomingTool } from '@/cypress/support/utils';
+import {
+ cypressPassThroughTestsFactory,
+ testChartLegendConfig,
+ testChartZoomingTool,
+ testStackAggregateTotals,
+} from '@/cypress/support/utils';
const dimensions = [
{
@@ -83,4 +88,13 @@ describe('ColumnChart', () => {
testChartLegendConfig(ColumnChart, { dataset: complexDataSet, dimensions, measures });
cypressPassThroughTestsFactory(ColumnChart, { dimensions: [], measures: [] });
+
+ testStackAggregateTotals(ColumnChart, {
+ dataset: complexDataSet.slice(0, 3),
+ dimensions,
+ measures: [
+ { accessor: 'users', stackId: 'A', label: 'Users' },
+ { accessor: 'sessions', stackId: 'A', label: 'Active Sessions' },
+ ],
+ });
});
diff --git a/packages/charts/src/components/ColumnChart/ColumnChart.mdx b/packages/charts/src/components/ColumnChart/ColumnChart.mdx
index f526044a839..d4ea30cd4ad 100644
--- a/packages/charts/src/components/ColumnChart/ColumnChart.mdx
+++ b/packages/charts/src/components/ColumnChart/ColumnChart.mdx
@@ -3,6 +3,8 @@ import { Canvas, Meta } from '@storybook/addon-docs/blocks';
import TooltipStory from '../../resources/TooltipConfig.mdx';
import LegendStory from '../../resources/LegendConfig.mdx';
import NormalizedStackedChartStory from '../../resources/NormalizedStackedChart.mdx';
+import StackAggregateTotalsStory from '../../resources/StackAggregateTotals.mdx';
+import CustomTooltipTotalStory from '../../resources/CustomTooltipTotal.mdx';
import * as ComponentStories from './ColumnChart.stories';
@@ -62,10 +64,20 @@ You can set a reference line to any value by using the `referenceLine` `chartCon
-## With Highlighted Measures
+### With Highlighted Measures
+You can display a total label at the top of each stacked column group by setting chartConfig.showStackAggregateTotals to true. The tooltip includes the total automatically when only a single column per dimension is present.>}
+/>
+
+When multiple columns per dimension are present (e.g. stacked + standalone), the built-in tooltip total is not available. You can provide a custom tooltip via the tooltipConfig.content prop to display a total for specific measures.>}
+/>
+
;
diff --git a/packages/charts/src/components/ColumnChart/ColumnChart.stories.tsx b/packages/charts/src/components/ColumnChart/ColumnChart.stories.tsx
index ec42f12829e..be2bea0d526 100644
--- a/packages/charts/src/components/ColumnChart/ColumnChart.stories.tsx
+++ b/packages/charts/src/components/ColumnChart/ColumnChart.stories.tsx
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
complexDataSet,
+ CustomTooltipContent,
legendConfig,
secondaryDimensionDataSet,
simpleDataSet,
@@ -156,6 +157,57 @@ export const WithHighlightedMeasure: Story = {
},
};
+export const WithStackAggregateTotals: Story = {
+ args: {
+ dataset: complexDataSet.slice(0, 3),
+ dimensions: [{ accessor: 'name' }],
+ measures: [
+ {
+ accessor: 'users',
+ stackId: 'A',
+ label: 'Users',
+ },
+ {
+ accessor: 'sessions',
+ stackId: 'A',
+ label: 'Active Sessions',
+ },
+ ],
+ chartConfig: {
+ showStackAggregateTotals: true,
+ },
+ },
+};
+
+export const WithCustomTooltipTotal: Story = {
+ args: {
+ dataset: complexDataSet.slice(0, 5),
+ dimensions: [{ accessor: 'name' }],
+ measures: [
+ {
+ accessor: 'users',
+ stackId: 'A',
+ label: 'Users',
+ },
+ {
+ accessor: 'sessions',
+ stackId: 'A',
+ label: 'Active Sessions',
+ },
+ {
+ accessor: 'volume',
+ label: 'Vol.',
+ },
+ ],
+ chartConfig: {
+ showStackAggregateTotals: true,
+ },
+ tooltipConfig: {
+ content: ,
+ },
+ },
+};
+
export const WithCustomTooltipConfig: Story = {
args: tooltipConfig,
};
diff --git a/packages/charts/src/components/ColumnChart/index.tsx b/packages/charts/src/components/ColumnChart/index.tsx
index 578d128d5a7..4317379f0be 100644
--- a/packages/charts/src/components/ColumnChart/index.tsx
+++ b/packages/charts/src/components/ColumnChart/index.tsx
@@ -33,6 +33,8 @@ import type { IChartMeasure } from '../../interfaces/IChartMeasure.js';
import { ChartContainer } from '../../internal/ChartContainer.js';
import { ChartDataLabel } from '../../internal/ChartDataLabel.js';
import { defaultFormatter } from '../../internal/defaults.js';
+import { StackAggregateLabel } from '../../internal/StackAggregateLabel.js';
+import { StackedTooltipContent } from '../../internal/StackedTooltipContent.js';
import { brushProps, tickLineConfig, tooltipContentStyle, tooltipFillOpacity } from '../../internal/staticProps.js';
import { getCellColors, resolvePrimaryAndSecondaryMeasures } from '../../internal/Utils.js';
import { XAxisTicks } from '../../internal/XAxisTicks.js';
@@ -165,11 +167,12 @@ const ColumnChart = forwardRef((props, ref) =>
};
const { referenceLine } = chartConfig;
- const { dimensions, measures } = usePrepareDimensionsAndMeasures(
+ const { dimensions, measures, stackGroups, lastInStack } = usePrepareDimensionsAndMeasures(
props.dimensions,
props.measures,
dimensionDefaults,
measureDefaults,
+ chartConfig.showStackAggregateTotals,
);
const tooltipValueFormatter = useTooltipFormatter(measures);
@@ -225,6 +228,10 @@ const ColumnChart = forwardRef((props, ref) =>
const { isMounted, handleBarAnimationStart, handleBarAnimationEnd } = useCancelAnimationFallback(noAnimation);
+ const stackGroupKeys = Object.keys(stackGroups);
+ const showStackTotalInTooltip =
+ chartConfig.showStackAggregateTotals && stackGroupKeys.length === 1 && measures.every((m) => m.stackId != null);
+
return (
((props, ref) =>
valueAccessor={valueAccessor(element.accessor)}
content={}
/>
+ {chartConfig.showStackAggregateTotals &&
+ element.stackId &&
+ typeof element.accessor === 'string' &&
+ lastInStack.has(element.accessor) && (
+ }
+ />
+ )}
{dataset.map((data, i) => {
return (
((props, ref) =>
contentStyle={tooltipContentStyle}
labelFormatter={tooltipLabelFormatter}
{...tooltipConfig}
+ {...(showStackTotalInTooltip && {
+ content: (
+
+ ),
+ })}
/>
)}
{!!chartConfig.zoomingTool && (
diff --git a/packages/charts/src/components/ComposedChart/ComposedChart.cy.tsx b/packages/charts/src/components/ComposedChart/ComposedChart.cy.tsx
index e1b0505d481..a82eacb3460 100644
--- a/packages/charts/src/components/ComposedChart/ComposedChart.cy.tsx
+++ b/packages/charts/src/components/ComposedChart/ComposedChart.cy.tsx
@@ -1,6 +1,11 @@
import { complexDataSet } from '../../resources/DemoProps.js';
import { ComposedChart } from './index.js';
-import { cypressPassThroughTestsFactory, testChartLegendConfig, testChartZoomingTool } from '@/cypress/support/utils';
+import {
+ cypressPassThroughTestsFactory,
+ testChartLegendConfig,
+ testChartZoomingTool,
+ testStackAggregateTotals,
+} from '@/cypress/support/utils';
const dimensions = [
{
@@ -90,4 +95,13 @@ describe('ComposedChart', () => {
testChartLegendConfig(ComposedChart, { dataset: complexDataSet, dimensions, measures });
cypressPassThroughTestsFactory(ComposedChart, { dimensions: [], measures: [] });
+
+ testStackAggregateTotals(ComposedChart, {
+ dataset: complexDataSet.slice(0, 3),
+ dimensions,
+ measures: [
+ { accessor: 'users', stackId: 'A', label: 'Users', type: 'bar' },
+ { accessor: 'sessions', stackId: 'A', label: 'Active Sessions', type: 'bar' },
+ ],
+ });
});
diff --git a/packages/charts/src/components/ComposedChart/ComposedChart.mdx b/packages/charts/src/components/ComposedChart/ComposedChart.mdx
index bfc2f5e8c36..d6bad2527ad 100644
--- a/packages/charts/src/components/ComposedChart/ComposedChart.mdx
+++ b/packages/charts/src/components/ComposedChart/ComposedChart.mdx
@@ -2,6 +2,8 @@ import { ControlsWithNote, DocsHeader, Footer } from '@sb/components';
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
import TooltipStory from '../../resources/TooltipConfig.mdx';
import LegendStory from '../../resources/LegendConfig.mdx';
+import StackAggregateTotalsStory from '../../resources/StackAggregateTotals.mdx';
+import CustomTooltipTotalStory from '../../resources/CustomTooltipTotal.mdx';
import * as ComponentStories from './ComposedChart.stories';
@@ -64,6 +66,16 @@ You can set a reference line to any value by using the `referenceLine` `chartCon
+You can display a total label at the top of each stacked bar group by setting chartConfig.showStackAggregateTotals to true. The tooltip includes the total automatically when all measures are stacked bars within a single stack group. If non-bar measures (e.g. lines or areas) are present, only the bar labels are shown.>}
+/>
+
+When non-bar measures (e.g. lines or areas) are present alongside stacked bars, the built-in tooltip total is not available. You can provide a custom tooltip via the tooltipConfig.content prop to display a total for the stacked bar measures.>}
+/>
+
diff --git a/packages/charts/src/components/ComposedChart/ComposedChart.stories.tsx b/packages/charts/src/components/ComposedChart/ComposedChart.stories.tsx
index 5a4050ea1ff..a9767e4dedc 100644
--- a/packages/charts/src/components/ComposedChart/ComposedChart.stories.tsx
+++ b/packages/charts/src/components/ComposedChart/ComposedChart.stories.tsx
@@ -1,5 +1,12 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
-import { bigDataSet, complexDataSet, legendConfig, simpleDataSet, tooltipConfig } from '../../resources/DemoProps.js';
+import {
+ bigDataSet,
+ complexDataSet,
+ CustomTooltipContent,
+ legendConfig,
+ simpleDataSet,
+ tooltipConfig,
+} from '../../resources/DemoProps.js';
import { ComposedChart } from './index.js';
const meta = {
@@ -205,6 +212,67 @@ export const LoadingPlaceholder: Story = {
},
};
+export const WithStackAggregateTotals: Story = {
+ args: {
+ dataset: complexDataSet.slice(0, 7),
+ dimensions: [{ accessor: 'name' }],
+ measures: [
+ {
+ accessor: 'users',
+ stackId: 'A',
+ label: 'Users',
+ type: 'bar',
+ },
+ {
+ accessor: 'sessions',
+ stackId: 'A',
+ label: 'Active Sessions',
+ type: 'bar',
+ },
+ {
+ accessor: 'volume',
+ label: 'Vol.',
+ type: 'line',
+ },
+ ],
+ chartConfig: {
+ showStackAggregateTotals: true,
+ },
+ },
+};
+
+export const WithCustomTooltipTotal: Story = {
+ args: {
+ dataset: complexDataSet.slice(0, 7),
+ dimensions: [{ accessor: 'name' }],
+ measures: [
+ {
+ accessor: 'users',
+ stackId: 'A',
+ label: 'Users',
+ type: 'bar',
+ },
+ {
+ accessor: 'sessions',
+ stackId: 'A',
+ label: 'Active Sessions',
+ type: 'bar',
+ },
+ {
+ accessor: 'volume',
+ label: 'Vol.',
+ type: 'line',
+ },
+ ],
+ chartConfig: {
+ showStackAggregateTotals: true,
+ },
+ tooltipConfig: {
+ content: ,
+ },
+ },
+};
+
export const WithCustomTooltipConfig: Story = {
args: tooltipConfig,
};
diff --git a/packages/charts/src/components/ComposedChart/index.tsx b/packages/charts/src/components/ComposedChart/index.tsx
index 34c4576cd72..9b771bffce2 100644
--- a/packages/charts/src/components/ComposedChart/index.tsx
+++ b/packages/charts/src/components/ComposedChart/index.tsx
@@ -34,6 +34,8 @@ import type { IChartMeasure } from '../../interfaces/IChartMeasure.js';
import { ChartContainer } from '../../internal/ChartContainer.js';
import { ChartDataLabel } from '../../internal/ChartDataLabel.js';
import { defaultFormatter } from '../../internal/defaults.js';
+import { StackAggregateLabel } from '../../internal/StackAggregateLabel.js';
+import { StackedTooltipContent } from '../../internal/StackedTooltipContent.js';
import { brushProps, tickLineConfig, tooltipContentStyle, tooltipFillOpacity } from '../../internal/staticProps.js';
import { getCellColors, resolvePrimaryAndSecondaryMeasures } from '../../internal/Utils.js';
import { XAxisTicks } from '../../internal/XAxisTicks.js';
@@ -184,11 +186,12 @@ const ComposedChart = forwardRef((props, ref
};
const { referenceLine } = chartConfig;
- const { dimensions, measures } = usePrepareDimensionsAndMeasures(
+ const { dimensions, measures, stackGroups, lastInStack } = usePrepareDimensionsAndMeasures(
props.dimensions,
props.measures,
dimensionDefaults,
measureDefaults,
+ chartConfig.showStackAggregateTotals,
);
const tooltipValueFormatter = useTooltipFormatter(measures);
@@ -274,6 +277,10 @@ const ComposedChart = forwardRef((props, ref
const { chartConfig: _0, dimensions: _1, measures: _2, ...propsWithoutOmitted } = rest;
const isRTL = useIsRTL(chartRef);
+ const stackGroupKeys = Object.keys(stackGroups);
+ const showStackTotalInTooltip =
+ chartConfig.showStackAggregateTotals && stackGroupKeys.length === 1 && measures.every((m) => m.stackId != null);
+
return (
((props, ref
contentStyle={tooltipContentStyle}
labelFormatter={tooltipLabelFormatter}
{...tooltipConfig}
+ {...(showStackTotalInTooltip && {
+ content: (
+
+ ),
+ })}
/>
)}
{!noLegend && (
@@ -514,6 +529,19 @@ const ComposedChart = forwardRef((props, ref
valueAccessor={valueAccessor(element.accessor)}
content={}
/>
+ {chartConfig.showStackAggregateTotals &&
+ element.stackId &&
+ typeof element.accessor === 'string' &&
+ lastInStack.has(element.accessor) && (
+
+ }
+ />
+ )}
{dataset.map((data, i) => {
return (
{
reactKey: 'b',
},
],
+ stackGroups: {},
+ lastInStack: new Set(),
});
});
@@ -65,6 +67,8 @@ describe('useLabelFormatter', () => {
reactKey: 'b',
},
],
+ stackGroups: {},
+ lastInStack: new Set(),
});
});
@@ -96,6 +100,8 @@ describe('useLabelFormatter', () => {
reactKey: 'b',
},
],
+ stackGroups: {},
+ lastInStack: new Set(),
});
});
});
diff --git a/packages/charts/src/hooks/usePrepareDimensionsAndMeasures.ts b/packages/charts/src/hooks/usePrepareDimensionsAndMeasures.ts
index 32127f53b95..06d5828ff1c 100644
--- a/packages/charts/src/hooks/usePrepareDimensionsAndMeasures.ts
+++ b/packages/charts/src/hooks/usePrepareDimensionsAndMeasures.ts
@@ -8,11 +8,15 @@ function getAccessorReactKey(accessorObj: Record) {
return reactKey;
}
+const emptyStackGroups: Record = {};
+const emptyLastInStack = new Set();
+
export const usePrepareDimensionsAndMeasures = (
rawDimensions,
rawMeasures,
dimensionDefaults = {},
measureDefaults = {},
+ showStackAggregateTotals = false,
) => {
const dimensions: DimensionConfig = useMemo(
() =>
@@ -26,17 +30,38 @@ export const usePrepareDimensionsAndMeasures =
- rawMeasures.map((measure) => {
- return {
- ...measureDefaults,
- ...measure,
- reactKey: getAccessorReactKey(measure),
- };
- }),
- [rawMeasures, measureDefaults],
- );
+ const { measures, stackGroups, lastInStack } = useMemo(() => {
+ const groups: Record = {};
+ const preparedMeasures = rawMeasures.map((measure) => {
+ const prepared = {
+ ...measureDefaults,
+ ...measure,
+ reactKey: getAccessorReactKey(measure),
+ };
+ if (showStackAggregateTotals && prepared.stackId && typeof prepared.accessor === 'string') {
+ if (!groups[prepared.stackId]) {
+ groups[prepared.stackId] = [];
+ }
+ groups[prepared.stackId].push(prepared.accessor);
+ }
+ return prepared;
+ });
+
+ if (!showStackAggregateTotals) {
+ return {
+ measures: preparedMeasures as MeasureConfig,
+ stackGroups: emptyStackGroups,
+ lastInStack: emptyLastInStack,
+ };
+ }
+
+ const last = new Set();
+ Object.values(groups).forEach((accessors) => {
+ last.add(accessors[accessors.length - 1]);
+ });
+
+ return { measures: preparedMeasures as MeasureConfig, stackGroups: groups, lastInStack: last };
+ }, [rawMeasures, measureDefaults, showStackAggregateTotals]);
- return { dimensions, measures };
+ return { dimensions, measures, stackGroups, lastInStack };
};
diff --git a/packages/charts/src/interfaces/ICartesianChartConfig.ts b/packages/charts/src/interfaces/ICartesianChartConfig.ts
index c9633cefe69..279f297841f 100644
--- a/packages/charts/src/interfaces/ICartesianChartConfig.ts
+++ b/packages/charts/src/interfaces/ICartesianChartConfig.ts
@@ -80,4 +80,15 @@ export interface ICartesianChartConfig {
* __Note:__ It is possible to overwrite internally used props. Please use with caution!
*/
secondXAxisConfig?: Omit;
+ /**
+ * Defines whether an aggregate total label should be displayed at the end of each stacked bar/column group.
+ *
+ * Only applies when measures use `stackId`. Non-stacked measures are not affected.
+ */
+ showStackAggregateTotals?: boolean;
+ /**
+ * Defines a custom formatter for the stack aggregate total label.
+ * If not set, the raw numeric total is displayed.
+ */
+ stackAggregateTotalFormatter?: (value: any) => string | number;
}
diff --git a/packages/charts/src/interfaces/IChartDimension.ts b/packages/charts/src/interfaces/IChartDimension.ts
index de23a60f551..de800be3b62 100644
--- a/packages/charts/src/interfaces/IChartDimension.ts
+++ b/packages/charts/src/interfaces/IChartDimension.ts
@@ -2,6 +2,8 @@ export interface IChartDimension {
/**
* A string containing the path to the dataset key this line should display.
* Supports object structures by using `'parent.child'`. Can also be a getter.
+ *
+ * __Note:__ Function accessors are not fully supported by all chart features (e.g. tooltips, event handlers, secondary axis, stack aggregate totals). For full feature support, use string accessors.
*/
accessor: string | ((dataset: Record) => string | number);
/**
diff --git a/packages/charts/src/interfaces/IChartMeasure.ts b/packages/charts/src/interfaces/IChartMeasure.ts
index 8153f8ee07a..81ffd501c09 100644
--- a/packages/charts/src/interfaces/IChartMeasure.ts
+++ b/packages/charts/src/interfaces/IChartMeasure.ts
@@ -4,6 +4,8 @@ export interface IChartMeasure {
/**
* A string containing the path to the dataset key this line should display.
* Supports object structures by using '`parent.child'`. Can also be a getter.
+ *
+ * __Note:__ Function accessors are not fully supported by all chart features (e.g. tooltips, event handlers, secondary axis, stack aggregate totals). For full feature support, use string accessors.
*/
accessor: string | ((data: any) => any);
/**
diff --git a/packages/charts/src/internal/StackAggregateLabel.tsx b/packages/charts/src/internal/StackAggregateLabel.tsx
new file mode 100644
index 00000000000..db07479a3b0
--- /dev/null
+++ b/packages/charts/src/internal/StackAggregateLabel.tsx
@@ -0,0 +1,39 @@
+import { ThemingParameters } from '@ui5/webcomponents-react-base';
+import type { ReactElement } from 'react';
+import { Label } from 'recharts';
+import type { LabelProps } from 'recharts';
+
+interface StackAggregateLabelProps {
+ stackAccessors: string[];
+ dataset: Record[];
+ // recharts LabelList props
+ viewBox?: { x: number; y: number; width: number; height: number };
+ index?: number;
+ offset?: number;
+ position?: LabelProps['position'];
+}
+
+export const StackAggregateLabel = (props: StackAggregateLabelProps): ReactElement | null => {
+ const { stackAccessors, dataset, viewBox, index, offset, position } = props;
+
+ if (index == null || !viewBox || !dataset?.[index]) {
+ return null;
+ }
+
+ const dataEntry = dataset[index];
+ const total = stackAccessors.reduce((sum, accessor) => {
+ return sum + (Number(dataEntry[accessor]) || 0);
+ }, 0);
+
+ return (
+
+ );
+};
diff --git a/packages/charts/src/internal/StackedTooltipContent.module.css b/packages/charts/src/internal/StackedTooltipContent.module.css
new file mode 100644
index 00000000000..c7c81bc04a3
--- /dev/null
+++ b/packages/charts/src/internal/StackedTooltipContent.module.css
@@ -0,0 +1,3 @@
+.withTotal :global(.recharts-tooltip-item:last-child) {
+ font-weight: bold;
+}
diff --git a/packages/charts/src/internal/StackedTooltipContent.tsx b/packages/charts/src/internal/StackedTooltipContent.tsx
new file mode 100644
index 00000000000..62908d6cf25
--- /dev/null
+++ b/packages/charts/src/internal/StackedTooltipContent.tsx
@@ -0,0 +1,58 @@
+import { ThemingParameters, useStylesheet } from '@ui5/webcomponents-react-base';
+import { useI18nBundle } from '@ui5/webcomponents-react-base/hooks';
+import type { ReactElement } from 'react';
+import { DefaultTooltipContent } from 'recharts';
+import { classNames, styleData } from './StackedTooltipContent.module.css.js';
+
+const TOTAL = { key: 'TOTAL', defaultText: 'Total' };
+
+interface StackedTooltipContentProps {
+ stackAccessors: string[];
+ totalFormatter?: (value: number) => string | number;
+ // recharts tooltip
+ payload?: Array<{
+ dataKey?: string;
+ value?: number;
+ name?: string;
+ color?: string;
+ payload?: Record;
+ }>;
+ [key: string]: unknown;
+}
+
+export const StackedTooltipContent = (props: StackedTooltipContentProps): ReactElement => {
+ const { stackAccessors, totalFormatter, payload, ...tooltipProps } = props;
+
+ useStylesheet(styleData, StackedTooltipContent.displayName);
+
+ const i18nBundle = useI18nBundle('@ui5/webcomponents-react');
+
+ if (!payload?.length) {
+ return ;
+ }
+
+ const firstEntry = payload[0]?.payload;
+ if (!firstEntry) {
+ return ;
+ }
+
+ const total = stackAccessors.reduce((sum, accessor) => {
+ return sum + (Number(firstEntry[accessor]) || 0);
+ }, 0);
+
+ const formattedTotal = totalFormatter ? totalFormatter(total) : total;
+
+ const augmentedPayload = [
+ ...payload,
+ {
+ name: i18nBundle.getText(TOTAL),
+ value: formattedTotal,
+ color: ThemingParameters.sapTextColor,
+ dataKey: '__stackTotal__',
+ },
+ ];
+
+ return ;
+};
+
+StackedTooltipContent.displayName = 'StackedTooltipContent';
diff --git a/packages/charts/src/resources/CustomTooltipTotal.mdx b/packages/charts/src/resources/CustomTooltipTotal.mdx
new file mode 100644
index 00000000000..3c127c4fa31
--- /dev/null
+++ b/packages/charts/src/resources/CustomTooltipTotal.mdx
@@ -0,0 +1,51 @@
+import { Canvas } from '@storybook/addon-docs/blocks';
+
+### With Custom Tooltip Total
+
+ {props.description}
+
+```jsx
+import { ThemingParameters } from '@ui5/webcomponents-react-base';
+import { DefaultTooltipContent } from 'recharts';
+
+const stackedAccessors = new Set(['users', 'sessions']);
+
+const CustomTooltipContent = (props) => {
+ const { payload, ...rest } = props;
+ if (!payload?.length) {
+ return ;
+ }
+ const stackedEntries = payload.filter((entry) => stackedAccessors.has(entry.dataKey));
+ if (!stackedEntries.length) {
+ return ;
+ }
+ const total = stackedEntries.reduce((sum, entry) => sum + (Number(entry.value) || 0), 0);
+ const augmentedPayload = [
+ ...payload,
+ {
+ name: `Total (${stackedEntries.map((entry) => entry.name).join(' + ')})`,
+ value: total,
+ color: ThemingParameters.sapTextColor,
+ },
+ ];
+ return (
+
+ );
+};
+
+// CSS: bold the Total row (last item)
+// .custom-stacked-tooltip .recharts-tooltip-item:last-child { font-weight: bold; }
+
+
+ }}
+/>
+```
+
+
diff --git a/packages/charts/src/resources/DemoProps.tsx b/packages/charts/src/resources/DemoProps.tsx
index 05be0f4e83c..6559aeba9ff 100644
--- a/packages/charts/src/resources/DemoProps.tsx
+++ b/packages/charts/src/resources/DemoProps.tsx
@@ -1,3 +1,6 @@
+import { ThemingParameters } from '@ui5/webcomponents-react-base';
+import { DefaultTooltipContent } from 'recharts';
+import type { TooltipProps } from 'recharts';
import type { IChartBaseProps } from '@/interfaces/IChartBaseProps.js';
export const legendConfig: IChartBaseProps = {
@@ -640,3 +643,27 @@ export const stackedNormalizedConfig = {
],
dataset: normalizeData(complexDataSet),
};
+
+const stackedAccessors = new Set(['users', 'sessions']);
+
+export const CustomTooltipContent = ({ payload, ...rest }: TooltipProps) => {
+ if (!payload?.length) {
+ return ;
+ }
+ const stackedEntries = payload.filter((entry) => stackedAccessors.has(entry.dataKey as string));
+ if (!stackedEntries.length) {
+ return ;
+ }
+ const total = stackedEntries.reduce((sum, entry) => sum + (Number(entry.value) || 0), 0);
+ const augmentedPayload = [
+ ...payload,
+ {
+ name: `Total (${stackedEntries.map((entry) => entry.name).join(' + ')})`,
+ value: total,
+ color: ThemingParameters.sapTextColor,
+ },
+ ];
+ return ;
+};
+
+CustomTooltipContent.displayName = 'CustomTooltipContent';
diff --git a/packages/charts/src/resources/StackAggregateTotals.mdx b/packages/charts/src/resources/StackAggregateTotals.mdx
new file mode 100644
index 00000000000..6f254e298d6
--- /dev/null
+++ b/packages/charts/src/resources/StackAggregateTotals.mdx
@@ -0,0 +1,7 @@
+import { Canvas } from '@storybook/addon-docs/blocks';
+
+### With Stack Aggregate Totals
+
+{props.description}
+
+
diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties
index 956ee6d4fd1..cb1b23abe1e 100644
--- a/packages/main/src/i18n/messagebundle.properties
+++ b/packages/main/src/i18n/messagebundle.properties
@@ -404,3 +404,6 @@ LIST_INTERACTIVE_ITEMS=List with interactive items.
#XACT: Keyboard navigation instruction for focusing interactive content in a list.
MOVE_TO_CONTENT_F2=To move to the content press F2.
+
+#XTXT
+TOTAL=Total
| | |