diff --git a/projects/js-packages/charts/changelog/charts-119-pie-donut-semi-circle-charts-custom-legend b/projects/js-packages/charts/changelog/charts-119-pie-donut-semi-circle-charts-custom-legend new file mode 100644 index 0000000000000..3b51e8a6b9c57 --- /dev/null +++ b/projects/js-packages/charts/changelog/charts-119-pie-donut-semi-circle-charts-custom-legend @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Charts: Add custom legend support diff --git a/projects/js-packages/charts/src/components/legend/private/base-legend.tsx b/projects/js-packages/charts/src/components/legend/private/base-legend.tsx index 2b4239bd5cb00..b684122f9a638 100644 --- a/projects/js-packages/charts/src/components/legend/private/base-legend.tsx +++ b/projects/js-packages/charts/src/components/legend/private/base-legend.tsx @@ -79,6 +79,7 @@ export const BaseLegend: ForwardRefExoticComponent< itemDirection = 'row', legendLabelProps, legendItemClassName, + render, ...legendItemProps }, ref @@ -96,7 +97,9 @@ export const BaseLegend: ForwardRefExoticComponent< [ items ] ); - return ( + return render ? ( + render( items ) + ) : ( { expect( legendItems ).toHaveLength( 2 ); } ); } ); + + describe( 'custom render prop', () => { + test( 'calls render function with items', () => { + const renderFn = jest.fn( () =>
Custom Legend
); + render( ); + + expect( renderFn ).toHaveBeenCalledWith( defaultItems ); + expect( screen.getByTestId( 'custom-legend' ) ).toBeInTheDocument(); + } ); + + test( 'uses custom render instead of default legend markup', () => { + const renderFn = () => ( +
+ Custom rendering +
+ ); + render( ); + + // 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 ) => ( +
    + { items.map( ( item, index ) => ( +
  • + { item.label } + { item.value } +
  • + ) ) } +
+ ); + render( ); + + 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 ) => ( +
+ { items.length === 0 ? 'No items' : `${ items.length } items` } +
+ ); + render( ); + + expect( screen.getByTestId( 'custom-legend' ) ).toBeInTheDocument(); + expect( screen.getByText( 'No items' ) ).toBeInTheDocument(); + } ); + + test( 'custom render can create alternative layouts', () => { + const renderFn = ( items: typeof defaultItems ) => ( +
+ { items.map( ( item, index ) => ( +
+
+
{ item.label }
+
{ item.value }
+
+ ) ) } +
+ ); + render( ); + + 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 ) => ( +
+

Legend Title

+
+ { items.map( ( item, index ) => ( +
+ + + + { item.label }: + { item.value } +
+ ) ) } +
+
+ ); + render( ); + + 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 = () =>
Custom
; + const { rerender } = render( + + ); + + expect( screen.getByTestId( 'custom-legend' ) ).toBeInTheDocument(); + expect( screen.queryByTestId( 'legend-horizontal' ) ).not.toBeInTheDocument(); + + rerender( ); + + expect( screen.getByTestId( 'custom-legend' ) ).toBeInTheDocument(); + expect( screen.queryByTestId( 'legend-vertical' ) ).not.toBeInTheDocument(); + } ); + } ); } ); diff --git a/projects/js-packages/charts/src/components/legend/types.ts b/projects/js-packages/charts/src/components/legend/types.ts index 4aba432b91cd5..07bc3884990e4 100644 --- a/projects/js-packages/charts/src/components/legend/types.ts +++ b/projects/js-packages/charts/src/components/legend/types.ts @@ -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' > & { diff --git a/projects/js-packages/charts/src/components/pie-chart/pie-chart.module.scss b/projects/js-packages/charts/src/components/pie-chart/pie-chart.module.scss index 9fd1bbda41273..ccf2a2f206e07 100644 --- a/projects/js-packages/charts/src/components/pie-chart/pie-chart.module.scss +++ b/projects/js-packages/charts/src/components/pie-chart/pie-chart.module.scss @@ -2,4 +2,6 @@ display: flex; flex-direction: column; overflow: hidden; + align-items: center; + gap: 20px; } diff --git a/projects/js-packages/charts/src/components/pie-chart/pie-chart.tsx b/projects/js-packages/charts/src/components/pie-chart/pie-chart.tsx index 46685f898a1fb..20ec5f45340c8 100644 --- a/projects/js-packages/charts/src/components/pie-chart/pie-chart.tsx +++ b/projects/js-packages/charts/src/components/pie-chart/pie-chart.tsx @@ -142,7 +142,7 @@ const PieChartInternal = ( { legendShape = 'circle', size, thickness = 1, - padding = 20, + padding = 0, gapScale = 0, cornerScale = 0, showLabels = true, diff --git a/projects/js-packages/charts/src/components/pie-chart/stories/donut.stories.tsx b/projects/js-packages/charts/src/components/pie-chart/stories/donut.stories.tsx index 81d037bee16d9..f4a11fbbe784e 100644 --- a/projects/js-packages/charts/src/components/pie-chart/stories/donut.stories.tsx +++ b/projects/js-packages/charts/src/components/pie-chart/stories/donut.stories.tsx @@ -1,3 +1,10 @@ +/* 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, @@ -5,9 +12,10 @@ import { 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 > >; @@ -85,7 +93,6 @@ export const Default: Story = { resize: 'none', thickness: 0.5, gapScale: 0.03, - padding: 20, cornerScale: 0.03, withTooltips: true, data, @@ -197,6 +204,7 @@ export const WithLegend: Story = { args: { ...Default.args, showLegend: true, + containerHeight: '500px', }, }; @@ -264,6 +272,7 @@ export const WithCompositionLegend: Story = { args: { data, thickness: 0.5, + containerHeight: '500px', }, parameters: { docs: { @@ -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; +} ) => ( +
+ { items.map( ( item, index ) => { + const { color } = chartItems[ index ]; + + return ( + + +
+ { item.label } + + + { item.formattedValue } + + + { withComparison && item.comparison } + + + ); + } ) } +
+); + +export const CustomLegend: Story = { + render: args => ( + + ( + + ) } + /> + + ), + 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.', + }, + }, + }, +}; diff --git a/projects/js-packages/charts/src/components/pie-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/components/pie-chart/stories/index.stories.tsx index 90c60a10992d3..267ca7b14b278 100644 --- a/projects/js-packages/charts/src/components/pie-chart/stories/index.stories.tsx +++ b/projects/js-packages/charts/src/components/pie-chart/stories/index.stories.tsx @@ -108,7 +108,6 @@ export const Default: Story = { args: { thickness: 1, gapScale: 0, - padding: 20, cornerScale: 0, withTooltips: false, data, @@ -137,6 +136,7 @@ export const WithLegend: Story = { args: { ...Default.args, showLegend: true, + containerHeight: '500px', }, }; @@ -180,6 +180,7 @@ export const WithCompositionLegend: Story = { ), args: { data, + containerHeight: '500px', }, parameters: { docs: { @@ -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: [ { @@ -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: { diff --git a/projects/js-packages/charts/src/components/pie-semi-circle-chart/pie-semi-circle-chart.module.scss b/projects/js-packages/charts/src/components/pie-semi-circle-chart/pie-semi-circle-chart.module.scss index 74c8ef0e9a8c4..7d178b7078335 100644 --- a/projects/js-packages/charts/src/components/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +++ b/projects/js-packages/charts/src/components/pie-semi-circle-chart/pie-semi-circle-chart.module.scss @@ -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 diff --git a/projects/js-packages/charts/src/index.ts b/projects/js-packages/charts/src/index.ts index 8862b01252840..a767e55e891dc 100644 --- a/projects/js-packages/charts/src/index.ts +++ b/projects/js-packages/charts/src/index.ts @@ -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'; diff --git a/projects/js-packages/charts/src/providers/chart-context/themes.ts b/projects/js-packages/charts/src/providers/chart-context/themes.ts index ce6c74dac4b37..16f06f1224da7 100644 --- a/projects/js-packages/charts/src/providers/chart-context/themes.ts +++ b/projects/js-packages/charts/src/providers/chart-context/themes.ts @@ -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 '#7B90FF', // Periwinkle blue '#EB6594', // Pink/rose ], diff --git a/projects/js-packages/charts/src/stories/sample-data/index.ts b/projects/js-packages/charts/src/stories/sample-data/index.ts index 24151bfdc92b3..81fc5d657ebe7 100644 --- a/projects/js-packages/charts/src/stories/sample-data/index.ts +++ b/projects/js-packages/charts/src/stories/sample-data/index.ts @@ -883,3 +883,49 @@ export const globalMarketComparisonByCountry: SeriesData[] = [ }, }, ]; + +/** + * Customer segmentation revenue data + * + * Revenue comparison between new and returning customers + * - Category: categorical + * - Data points: 2 + * - Suitable for: PieChart, DonutChart + */ +export const customerRevenueData: DataPointPercentage[] = [ + { + label: 'New', + value: 302331.27, + valueDisplay: '$302.33K', + percentage: 66.97, + }, + { + label: 'Returning', + value: 149111.41, + valueDisplay: '$149.11K', + percentage: 33.03, + }, +]; + +/** + * Customer segmentation legend data with comparison metrics + * + * Extended legend data for customer revenue with growth comparisons + * - Category: categorical with comparison + * - Data points: 2 + * - Suitable for: Custom legends with PieChart, DonutChart + */ +export const customerRevenueLegendData = [ + { + label: 'New', + value: 302331.27, + formattedValue: '$302.33K', + comparison: '14%', + }, + { + label: 'Returning', + value: 149111.41, + formattedValue: '$149.11K', + comparison: '133%', + }, +];