Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Charts: Add custom legend support
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export const BaseLegend: ForwardRefExoticComponent<
itemDirection = 'row',
legendLabelProps,
legendItemClassName,
render,
...legendItemProps
},
ref
Expand All @@ -96,7 +97,9 @@ export const BaseLegend: ForwardRefExoticComponent<
[ items ]
);

return (
return render ? (
render( items )
) : (
<LegendOrdinal
scale={ legendScale }
labelFormat={ labelFormat }
Expand Down
119 changes: 119 additions & 0 deletions projects/js-packages/charts/src/components/legend/test/legend.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable react/jsx-no-bind */
import { render, screen } from '@testing-library/react';
import { BaseLegend } from '../private/base-legend';
import type { LegendProps } from '../types';
Expand Down Expand Up @@ -207,4 +208,122 @@ describe( 'BaseLegend', () => {
expect( legendItems ).toHaveLength( 2 );
} );
} );

describe( 'custom render prop', () => {
test( 'calls render function with items', () => {
const renderFn = jest.fn( () => <div data-testid="custom-legend">Custom Legend</div> );
render( <BaseLegend items={ defaultItems } orientation="horizontal" render={ renderFn } /> );

expect( renderFn ).toHaveBeenCalledWith( defaultItems );
expect( screen.getByTestId( 'custom-legend' ) ).toBeInTheDocument();
} );

test( 'uses custom render instead of default legend markup', () => {
const renderFn = () => (
<div data-testid="custom-legend">
<span>Custom rendering</span>
</div>
);
render( <BaseLegend items={ defaultItems } orientation="horizontal" render={ renderFn } /> );

// Custom markup should be present
expect( screen.getByTestId( 'custom-legend' ) ).toBeInTheDocument();
expect( screen.getByText( 'Custom rendering' ) ).toBeInTheDocument();

// Default legend markup should not be present
expect( screen.queryByTestId( 'legend-horizontal' ) ).not.toBeInTheDocument();
expect( screen.queryByTestId( 'legend-item' ) ).not.toBeInTheDocument();
} );

test( 'custom render can access all item properties', () => {
const renderFn = ( items: typeof defaultItems ) => (
<ul data-testid="custom-legend-list">
{ items.map( ( item, index ) => (
<li key={ index } data-testid={ `custom-item-${ index }` }>
<span style={ { color: item.color } }>{ item.label }</span>
<span>{ item.value }</span>
</li>
) ) }
</ul>
);
render( <BaseLegend items={ defaultItems } orientation="horizontal" render={ renderFn } /> );

expect( screen.getByTestId( 'custom-legend-list' ) ).toBeInTheDocument();
expect( screen.getByTestId( 'custom-item-0' ) ).toBeInTheDocument();
expect( screen.getByTestId( 'custom-item-1' ) ).toBeInTheDocument();
expect( screen.getByText( 'Item 1' ) ).toBeInTheDocument();
expect( screen.getByText( 'Item 2' ) ).toBeInTheDocument();
} );

test( 'custom render handles empty items array', () => {
const renderFn = ( items: typeof defaultItems ) => (
<div data-testid="custom-legend">
{ items.length === 0 ? 'No items' : `${ items.length } items` }
</div>
);
render( <BaseLegend items={ [] } orientation="horizontal" render={ renderFn } /> );

expect( screen.getByTestId( 'custom-legend' ) ).toBeInTheDocument();
expect( screen.getByText( 'No items' ) ).toBeInTheDocument();
} );

test( 'custom render can create alternative layouts', () => {
const renderFn = ( items: typeof defaultItems ) => (
<div data-testid="custom-grid-legend" style={ { display: 'grid' } }>
{ items.map( ( item, index ) => (
<div key={ index } data-testid="grid-item">
<div style={ { backgroundColor: item.color, width: 20, height: 20 } } />
<div>{ item.label }</div>
<div>{ item.value }</div>
</div>
) ) }
</div>
);
render( <BaseLegend items={ defaultItems } orientation="horizontal" render={ renderFn } /> );

expect( screen.getByTestId( 'custom-grid-legend' ) ).toBeInTheDocument();
const gridItems = screen.getAllByTestId( 'grid-item' );
expect( gridItems ).toHaveLength( 2 );
} );

test( 'custom render with complex JSX structure', () => {
const renderFn = ( items: typeof defaultItems ) => (
<div data-testid="complex-legend">
<h3>Legend Title</h3>
<div className="legend-body">
{ items.map( ( item, index ) => (
<div key={ index } className="legend-row">
<svg width={ 10 } height={ 10 }>
<circle cx={ 5 } cy={ 5 } r={ 5 } fill={ item.color } />
</svg>
<span>{ item.label }: </span>
<strong>{ item.value }</strong>
</div>
) ) }
</div>
</div>
);
render( <BaseLegend items={ defaultItems } orientation="horizontal" render={ renderFn } /> );

expect( screen.getByTestId( 'complex-legend' ) ).toBeInTheDocument();
expect( screen.getByText( 'Legend Title' ) ).toBeInTheDocument();
expect( screen.getByText( 'Item 1:' ) ).toBeInTheDocument();
expect( screen.getByText( '50%' ) ).toBeInTheDocument();
} );

test( 'orientation prop is ignored when using custom render', () => {
const renderFn = () => <div data-testid="custom-legend">Custom</div>;
const { rerender } = render(
<BaseLegend items={ defaultItems } orientation="horizontal" render={ renderFn } />
);

expect( screen.getByTestId( 'custom-legend' ) ).toBeInTheDocument();
expect( screen.queryByTestId( 'legend-horizontal' ) ).not.toBeInTheDocument();

rerender( <BaseLegend items={ defaultItems } orientation="vertical" render={ renderFn } /> );

expect( screen.getByTestId( 'custom-legend' ) ).toBeInTheDocument();
expect( screen.queryByTestId( 'legend-vertical' ) ).not.toBeInTheDocument();
} );
} );
} );
4 changes: 4 additions & 0 deletions projects/js-packages/charts/src/components/legend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export type BaseLegendProps = Omit< LegendOrdinalProps, 'shapeStyle' > & {
* This allows consumers to customize individual legend item styling.
*/
legendItemClassName?: string;
/**
* Function for rendering a custom legend layout.
*/
render?: ( items: BaseLegendItem[] ) => ReactNode;
};

export type LegendProps = Omit< BaseLegendProps, 'items' > & {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@
display: flex;
flex-direction: column;
overflow: hidden;
align-items: center;
gap: 20px;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add some default space between the chart and legend. Can be customised using a className.

}
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ const PieChartInternal = ( {
legendShape = 'circle',
size,
thickness = 1,
padding = 20,
padding = 0,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is being set to 0 by our consumers and it seems a more sensible default

gapScale = 0,
cornerScale = 0,
showLabels = true,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
/* eslint-disable @wordpress/no-unsafe-wp-apis */
import {
__experimentalText as WPText,
__experimentalHStack as HStack,
} from '@wordpress/components';
import { Fragment } from 'react';
import { BaseLegendItem } from '../../../components/legend/types';
import {
chartDecorator,
sharedChartArgTypes,
ChartStoryArgs,
legendArgTypes,
themeArgTypes,
} from '../../../stories';
import { customerRevenueData, customerRevenueLegendData } from '../../../stories/sample-data';
import { Group } from '../../../visx/group';
import { Text } from '../../../visx/text';
import { PieChart } from '../../pie-chart';
import { PieChart, PieChartUnresponsive } from '../../pie-chart';
import type { Meta, StoryObj } from '@storybook/react';

type StoryArgs = ChartStoryArgs< React.ComponentProps< typeof PieChart > >;
Expand Down Expand Up @@ -85,7 +93,6 @@ export const Default: Story = {
resize: 'none',
thickness: 0.5,
gapScale: 0.03,
padding: 20,
cornerScale: 0.03,
withTooltips: true,
data,
Expand Down Expand Up @@ -197,6 +204,7 @@ export const WithLegend: Story = {
args: {
...Default.args,
showLegend: true,
containerHeight: '500px',
},
};

Expand Down Expand Up @@ -264,6 +272,7 @@ export const WithCompositionLegend: Story = {
args: {
data,
thickness: 0.5,
containerHeight: '500px',
},
parameters: {
docs: {
Expand Down Expand Up @@ -319,3 +328,83 @@ export const CustomLegendPositioning: Story = {
},
},
};

const WooPieLegend = ( {
chartItems,
items,
withComparison,
}: {
chartItems: BaseLegendItem[];
items: { label: string; value: number; formattedValue: string; comparison: string }[];
withComparison: boolean;
} ) => (
<div
style={ {
display: 'inline-grid',
gridTemplateColumns: '1fr auto auto',
gap: 'var(--wpds-spacing-05, 5px) var(--wpds-spacing-10, 10px)',
} }
>
{ items.map( ( item, index ) => {
const { color } = chartItems[ index ];
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extract the color from the legend items in the global charts context, which have a resolved color based on the theme, group, overrides, etc.


return (
<Fragment key={ index }>
<HStack direction="row" justify="flex-start" gap={ 2 }>
<div
style={ {
width: '8px',
height: '8px',
borderRadius: '50%',
flexShrink: 0,
backgroundColor: color,
} }
/>
<WPText size="small">{ item.label }</WPText>
</HStack>
<WPText size="small" weight={ 600 } style={ { textAlign: 'right' } }>
{ item.formattedValue }
</WPText>
<WPText size="small" style={ { textAlign: 'right', color: '#008a20' } }>
{ withComparison && item.comparison }
</WPText>
</Fragment>
);
} ) }
</div>
);

export const CustomLegend: Story = {
render: args => (
<PieChartUnresponsive { ...args }>
<PieChartUnresponsive.Legend
// eslint-disable-next-line react/jsx-no-bind
render={ items => (
<WooPieLegend
chartItems={ items }
items={ customerRevenueLegendData }
withComparison={ args.withComparison }
/>
) }
/>
</PieChartUnresponsive>
),
args: {
...Default.args,
data: customerRevenueData.map( segment => ( { ...segment, label: '' } ) ),
thickness: 0.3,
cornerScale: 0.03,
gapScale: 0.01,
size: 164,
withComparison: true,
withTooltips: false,
containerHeight: '300px',
},
parameters: {
docs: {
description: {
story: 'Demonstrates how to customize the legend using the render prop.',
},
},
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@ export const Default: Story = {
args: {
thickness: 1,
gapScale: 0,
padding: 20,
cornerScale: 0,
withTooltips: false,
data,
Expand Down Expand Up @@ -137,6 +136,7 @@ export const WithLegend: Story = {
args: {
...Default.args,
showLegend: true,
containerHeight: '500px',
},
};

Expand Down Expand Up @@ -180,6 +180,7 @@ export const WithCompositionLegend: Story = {
),
args: {
data,
containerHeight: '500px',
},
parameters: {
docs: {
Expand Down Expand Up @@ -341,6 +342,7 @@ This pattern provides:
export const CustomLabelColors: Story = {
args: {
...Default.args,
showLegend: true,
thickness: 0.85, // Slightly thinner for better label visibility
data: [
{
Expand Down Expand Up @@ -368,6 +370,7 @@ export const CustomLabelColors: Story = {
labelTextColor: '#FFFFFF', // White text for contrast against dark background
labelBackgroundColor: 'rgba(0, 0, 0, 0.75)', // Dark semi-transparent background
size: 400,
containerHeight: '500px',
},
parameters: {
docs: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
display: flex;
flex-direction: column;
text-align: center;

gap: 20px;

.label {
margin-bottom: 0; // Add space between label and pie chart
Expand Down
2 changes: 1 addition & 1 deletion projects/js-packages/charts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export { ConversionFunnelChart } from './components/conversion-funnel-chart';
// Chart components
export { BaseTooltip } from './components/tooltip';
export { Legend, useChartLegendItems } from './components/legend';
export type { LegendValueDisplay } from './components/legend';
export type { LegendValueDisplay, BaseLegendItem } from './components/legend';

// Themes
export { GlobalChartsProvider as ThemeProvider } from './providers';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,8 @@ const wooTheme: ChartTheme = {
labelTextColor: '#FFFFFF', // label text color (white to match original behavior)
colors: [
'#3858E9', // WooCommerce brand blue
'#873EFF', // Purple
'#66BDFF', // Light blue
'#873EFF', // Purple
Comment on lines -132 to +133
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swap this order to match that in Woo Analytics

'#7B90FF', // Periwinkle blue
'#EB6594', // Pink/rose
],
Expand Down
Loading
Loading