Skip to content

Commit c0890ac

Browse files
authored
Charts: Add custom legend rendering (#45347)
* Add custom render function to Legend * Improve Pie chart layout by removing default padding and controlling spacing using column gap * Update Woo colors to match the app * Add custom legend story for Donut chart Replicates usage in Woo * Add changelog * Update Pie chart stories * Clean up story and data * Enhance legend type exports by adding BaseLegendItem to the chart components * Make gap consistent * Improve story props and add keys * Add tests
1 parent e23914d commit c0890ac

File tree

12 files changed

+278
-8
lines changed

12 files changed

+278
-8
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: added
3+
4+
Charts: Add custom legend support

projects/js-packages/charts/src/components/legend/private/base-legend.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export const BaseLegend: ForwardRefExoticComponent<
7979
itemDirection = 'row',
8080
legendLabelProps,
8181
legendItemClassName,
82+
render,
8283
...legendItemProps
8384
},
8485
ref
@@ -96,7 +97,9 @@ export const BaseLegend: ForwardRefExoticComponent<
9697
[ items ]
9798
);
9899

99-
return (
100+
return render ? (
101+
render( items )
102+
) : (
100103
<LegendOrdinal
101104
scale={ legendScale }
102105
labelFormat={ labelFormat }

projects/js-packages/charts/src/components/legend/test/legend.test.tsx

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable react/jsx-no-bind */
12
import { render, screen } from '@testing-library/react';
23
import { BaseLegend } from '../private/base-legend';
34
import type { LegendProps } from '../types';
@@ -207,4 +208,122 @@ describe( 'BaseLegend', () => {
207208
expect( legendItems ).toHaveLength( 2 );
208209
} );
209210
} );
211+
212+
describe( 'custom render prop', () => {
213+
test( 'calls render function with items', () => {
214+
const renderFn = jest.fn( () => <div data-testid="custom-legend">Custom Legend</div> );
215+
render( <BaseLegend items={ defaultItems } orientation="horizontal" render={ renderFn } /> );
216+
217+
expect( renderFn ).toHaveBeenCalledWith( defaultItems );
218+
expect( screen.getByTestId( 'custom-legend' ) ).toBeInTheDocument();
219+
} );
220+
221+
test( 'uses custom render instead of default legend markup', () => {
222+
const renderFn = () => (
223+
<div data-testid="custom-legend">
224+
<span>Custom rendering</span>
225+
</div>
226+
);
227+
render( <BaseLegend items={ defaultItems } orientation="horizontal" render={ renderFn } /> );
228+
229+
// Custom markup should be present
230+
expect( screen.getByTestId( 'custom-legend' ) ).toBeInTheDocument();
231+
expect( screen.getByText( 'Custom rendering' ) ).toBeInTheDocument();
232+
233+
// Default legend markup should not be present
234+
expect( screen.queryByTestId( 'legend-horizontal' ) ).not.toBeInTheDocument();
235+
expect( screen.queryByTestId( 'legend-item' ) ).not.toBeInTheDocument();
236+
} );
237+
238+
test( 'custom render can access all item properties', () => {
239+
const renderFn = ( items: typeof defaultItems ) => (
240+
<ul data-testid="custom-legend-list">
241+
{ items.map( ( item, index ) => (
242+
<li key={ index } data-testid={ `custom-item-${ index }` }>
243+
<span style={ { color: item.color } }>{ item.label }</span>
244+
<span>{ item.value }</span>
245+
</li>
246+
) ) }
247+
</ul>
248+
);
249+
render( <BaseLegend items={ defaultItems } orientation="horizontal" render={ renderFn } /> );
250+
251+
expect( screen.getByTestId( 'custom-legend-list' ) ).toBeInTheDocument();
252+
expect( screen.getByTestId( 'custom-item-0' ) ).toBeInTheDocument();
253+
expect( screen.getByTestId( 'custom-item-1' ) ).toBeInTheDocument();
254+
expect( screen.getByText( 'Item 1' ) ).toBeInTheDocument();
255+
expect( screen.getByText( 'Item 2' ) ).toBeInTheDocument();
256+
} );
257+
258+
test( 'custom render handles empty items array', () => {
259+
const renderFn = ( items: typeof defaultItems ) => (
260+
<div data-testid="custom-legend">
261+
{ items.length === 0 ? 'No items' : `${ items.length } items` }
262+
</div>
263+
);
264+
render( <BaseLegend items={ [] } orientation="horizontal" render={ renderFn } /> );
265+
266+
expect( screen.getByTestId( 'custom-legend' ) ).toBeInTheDocument();
267+
expect( screen.getByText( 'No items' ) ).toBeInTheDocument();
268+
} );
269+
270+
test( 'custom render can create alternative layouts', () => {
271+
const renderFn = ( items: typeof defaultItems ) => (
272+
<div data-testid="custom-grid-legend" style={ { display: 'grid' } }>
273+
{ items.map( ( item, index ) => (
274+
<div key={ index } data-testid="grid-item">
275+
<div style={ { backgroundColor: item.color, width: 20, height: 20 } } />
276+
<div>{ item.label }</div>
277+
<div>{ item.value }</div>
278+
</div>
279+
) ) }
280+
</div>
281+
);
282+
render( <BaseLegend items={ defaultItems } orientation="horizontal" render={ renderFn } /> );
283+
284+
expect( screen.getByTestId( 'custom-grid-legend' ) ).toBeInTheDocument();
285+
const gridItems = screen.getAllByTestId( 'grid-item' );
286+
expect( gridItems ).toHaveLength( 2 );
287+
} );
288+
289+
test( 'custom render with complex JSX structure', () => {
290+
const renderFn = ( items: typeof defaultItems ) => (
291+
<div data-testid="complex-legend">
292+
<h3>Legend Title</h3>
293+
<div className="legend-body">
294+
{ items.map( ( item, index ) => (
295+
<div key={ index } className="legend-row">
296+
<svg width={ 10 } height={ 10 }>
297+
<circle cx={ 5 } cy={ 5 } r={ 5 } fill={ item.color } />
298+
</svg>
299+
<span>{ item.label }: </span>
300+
<strong>{ item.value }</strong>
301+
</div>
302+
) ) }
303+
</div>
304+
</div>
305+
);
306+
render( <BaseLegend items={ defaultItems } orientation="horizontal" render={ renderFn } /> );
307+
308+
expect( screen.getByTestId( 'complex-legend' ) ).toBeInTheDocument();
309+
expect( screen.getByText( 'Legend Title' ) ).toBeInTheDocument();
310+
expect( screen.getByText( 'Item 1:' ) ).toBeInTheDocument();
311+
expect( screen.getByText( '50%' ) ).toBeInTheDocument();
312+
} );
313+
314+
test( 'orientation prop is ignored when using custom render', () => {
315+
const renderFn = () => <div data-testid="custom-legend">Custom</div>;
316+
const { rerender } = render(
317+
<BaseLegend items={ defaultItems } orientation="horizontal" render={ renderFn } />
318+
);
319+
320+
expect( screen.getByTestId( 'custom-legend' ) ).toBeInTheDocument();
321+
expect( screen.queryByTestId( 'legend-horizontal' ) ).not.toBeInTheDocument();
322+
323+
rerender( <BaseLegend items={ defaultItems } orientation="vertical" render={ renderFn } /> );
324+
325+
expect( screen.getByTestId( 'custom-legend' ) ).toBeInTheDocument();
326+
expect( screen.queryByTestId( 'legend-vertical' ) ).not.toBeInTheDocument();
327+
} );
328+
} );
210329
} );

projects/js-packages/charts/src/components/legend/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ export type BaseLegendProps = Omit< LegendOrdinalProps, 'shapeStyle' > & {
2929
* This allows consumers to customize individual legend item styling.
3030
*/
3131
legendItemClassName?: string;
32+
/**
33+
* Function for rendering a custom legend layout.
34+
*/
35+
render?: ( items: BaseLegendItem[] ) => ReactNode;
3236
};
3337

3438
export type LegendProps = Omit< BaseLegendProps, 'items' > & {

projects/js-packages/charts/src/components/pie-chart/pie-chart.module.scss

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@
22
display: flex;
33
flex-direction: column;
44
overflow: hidden;
5+
align-items: center;
6+
gap: 20px;
57
}

projects/js-packages/charts/src/components/pie-chart/pie-chart.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ const PieChartInternal = ( {
142142
legendShape = 'circle',
143143
size,
144144
thickness = 1,
145-
padding = 20,
145+
padding = 0,
146146
gapScale = 0,
147147
cornerScale = 0,
148148
showLabels = true,

projects/js-packages/charts/src/components/pie-chart/stories/donut.stories.tsx

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
1+
/* eslint-disable @wordpress/no-unsafe-wp-apis */
2+
import {
3+
__experimentalText as WPText,
4+
__experimentalHStack as HStack,
5+
} from '@wordpress/components';
6+
import { Fragment } from 'react';
7+
import { BaseLegendItem } from '../../../components/legend/types';
18
import {
29
chartDecorator,
310
sharedChartArgTypes,
411
ChartStoryArgs,
512
legendArgTypes,
613
themeArgTypes,
714
} from '../../../stories';
15+
import { customerRevenueData, customerRevenueLegendData } from '../../../stories/sample-data';
816
import { Group } from '../../../visx/group';
917
import { Text } from '../../../visx/text';
10-
import { PieChart } from '../../pie-chart';
18+
import { PieChart, PieChartUnresponsive } from '../../pie-chart';
1119
import type { Meta, StoryObj } from '@storybook/react';
1220

1321
type StoryArgs = ChartStoryArgs< React.ComponentProps< typeof PieChart > >;
@@ -85,7 +93,6 @@ export const Default: Story = {
8593
resize: 'none',
8694
thickness: 0.5,
8795
gapScale: 0.03,
88-
padding: 20,
8996
cornerScale: 0.03,
9097
withTooltips: true,
9198
data,
@@ -197,6 +204,7 @@ export const WithLegend: Story = {
197204
args: {
198205
...Default.args,
199206
showLegend: true,
207+
containerHeight: '500px',
200208
},
201209
};
202210

@@ -264,6 +272,7 @@ export const WithCompositionLegend: Story = {
264272
args: {
265273
data,
266274
thickness: 0.5,
275+
containerHeight: '500px',
267276
},
268277
parameters: {
269278
docs: {
@@ -319,3 +328,83 @@ export const CustomLegendPositioning: Story = {
319328
},
320329
},
321330
};
331+
332+
const WooPieLegend = ( {
333+
chartItems,
334+
items,
335+
withComparison,
336+
}: {
337+
chartItems: BaseLegendItem[];
338+
items: { label: string; value: number; formattedValue: string; comparison: string }[];
339+
withComparison: boolean;
340+
} ) => (
341+
<div
342+
style={ {
343+
display: 'inline-grid',
344+
gridTemplateColumns: '1fr auto auto',
345+
gap: 'var(--wpds-spacing-05, 5px) var(--wpds-spacing-10, 10px)',
346+
} }
347+
>
348+
{ items.map( ( item, index ) => {
349+
const { color } = chartItems[ index ];
350+
351+
return (
352+
<Fragment key={ index }>
353+
<HStack direction="row" justify="flex-start" gap={ 2 }>
354+
<div
355+
style={ {
356+
width: '8px',
357+
height: '8px',
358+
borderRadius: '50%',
359+
flexShrink: 0,
360+
backgroundColor: color,
361+
} }
362+
/>
363+
<WPText size="small">{ item.label }</WPText>
364+
</HStack>
365+
<WPText size="small" weight={ 600 } style={ { textAlign: 'right' } }>
366+
{ item.formattedValue }
367+
</WPText>
368+
<WPText size="small" style={ { textAlign: 'right', color: '#008a20' } }>
369+
{ withComparison && item.comparison }
370+
</WPText>
371+
</Fragment>
372+
);
373+
} ) }
374+
</div>
375+
);
376+
377+
export const CustomLegend: Story = {
378+
render: args => (
379+
<PieChartUnresponsive { ...args }>
380+
<PieChartUnresponsive.Legend
381+
// eslint-disable-next-line react/jsx-no-bind
382+
render={ items => (
383+
<WooPieLegend
384+
chartItems={ items }
385+
items={ customerRevenueLegendData }
386+
withComparison={ args.withComparison }
387+
/>
388+
) }
389+
/>
390+
</PieChartUnresponsive>
391+
),
392+
args: {
393+
...Default.args,
394+
data: customerRevenueData.map( segment => ( { ...segment, label: '' } ) ),
395+
thickness: 0.3,
396+
cornerScale: 0.03,
397+
gapScale: 0.01,
398+
size: 164,
399+
withComparison: true,
400+
withTooltips: false,
401+
containerHeight: '300px',
402+
},
403+
parameters: {
404+
docs: {
405+
description: {
406+
story: 'Demonstrates how to customize the legend using the render prop.',
407+
},
408+
},
409+
},
410+
};

projects/js-packages/charts/src/components/pie-chart/stories/index.stories.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,6 @@ export const Default: Story = {
108108
args: {
109109
thickness: 1,
110110
gapScale: 0,
111-
padding: 20,
112111
cornerScale: 0,
113112
withTooltips: false,
114113
data,
@@ -137,6 +136,7 @@ export const WithLegend: Story = {
137136
args: {
138137
...Default.args,
139138
showLegend: true,
139+
containerHeight: '500px',
140140
},
141141
};
142142

@@ -180,6 +180,7 @@ export const WithCompositionLegend: Story = {
180180
),
181181
args: {
182182
data,
183+
containerHeight: '500px',
183184
},
184185
parameters: {
185186
docs: {
@@ -341,6 +342,7 @@ This pattern provides:
341342
export const CustomLabelColors: Story = {
342343
args: {
343344
...Default.args,
345+
showLegend: true,
344346
thickness: 0.85, // Slightly thinner for better label visibility
345347
data: [
346348
{
@@ -368,6 +370,7 @@ export const CustomLabelColors: Story = {
368370
labelTextColor: '#FFFFFF', // White text for contrast against dark background
369371
labelBackgroundColor: 'rgba(0, 0, 0, 0.75)', // Dark semi-transparent background
370372
size: 400,
373+
containerHeight: '500px',
371374
},
372375
parameters: {
373376
docs: {

projects/js-packages/charts/src/components/pie-semi-circle-chart/pie-semi-circle-chart.module.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
display: flex;
33
flex-direction: column;
44
text-align: center;
5-
5+
gap: 20px;
66

77
.label {
88
margin-bottom: 0; // Add space between label and pie chart

projects/js-packages/charts/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export { ConversionFunnelChart } from './components/conversion-funnel-chart';
1313
// Chart components
1414
export { BaseTooltip } from './components/tooltip';
1515
export { Legend, useChartLegendItems } from './components/legend';
16-
export type { LegendValueDisplay } from './components/legend';
16+
export type { LegendValueDisplay, BaseLegendItem } from './components/legend';
1717

1818
// Themes
1919
export { GlobalChartsProvider as ThemeProvider } from './providers';

0 commit comments

Comments
 (0)