+ {/* Chart with legend hidden but still registering data */}
+
+ {/* Standalone legend that automatically gets data from chart context */}
+
+
+`,
+ },
+ description: {
+ story: `
+## Standalone Legend with Chart Context Integration
+
+This example demonstrates the power of the Legend component's context integration feature.
+
+### How It Works
+
+1. **Chart Registration**: When a chart is rendered with a \`chartId\`, it automatically registers its legend data in the chart context
+2. **Data Retrieval**: The Legend component can then retrieve this data using the same \`chartId\`
+3. **Decoupled Display**: The legend can be placed anywhere in your layout, completely independent from the chart
+
+### Key Benefits
+
+- **Flexible Layouts**: Create complex dashboard layouts with centralized legend areas
+- **Consistent Legends**: Multiple charts can share legend styles and positioning
+- **Dynamic Updates**: Legend automatically updates when chart data changes
+- **No Prop Drilling**: No need to pass legend data through multiple component levels
+
+### Code Example
+
+\`\`\`jsx
+// Chart with hidden legend
+
+
+// Standalone legend that retrieves data automatically
+
+\`\`\`
+
+### Important Notes
+
+- The chart and legend must be wrapped in the same ChartProvider context
+- The \`chartId\` must match exactly between chart and legend
+- Charts with \`showLegend={false}\` still register their legend data
+- If no chart with the given \`chartId\` exists, the legend will render nothing
+`,
+ },
+ },
+ },
+};
+
+// Story showing a real-world dashboard layout with centralized legends
+const DashboardWithCentralizedLegend = () => {
+ return (
+
+
+ { /* Main content area with charts */ }
+
+
+
Revenue Trends
+
+
+
+
+
+
Sales by Quarter
+
+
+
+
+
Device Distribution
+
+
+
+
+
+ { /* Centralized legend panel */ }
+
+
+
+ );
+};
+
+export const DashboardExample: Story = {
+ render: () => ,
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ story: `
+## Real-World Dashboard Example
+
+This example demonstrates a complete dashboard implementation using Legend with chart context integration.
+
+### Key Implementation Details
+
+1. **Chart Setup**: Each chart has a unique \`chartId\` and \`showLegend={false}\`
+2. **Centralized Legends**: All legends are placed in a dedicated sidebar
+3. **Automatic Data Sync**: Legends automatically retrieve data from their respective charts
+4. **Clean Layout**: Charts remain uncluttered while legends are easily accessible
+
+### Benefits of This Approach
+
+- **Consistent Legend Styling**: All legends share the same visual style
+- **Space Efficiency**: Charts can use full width without legend taking up space
+- **Better Mobile Experience**: Legends can be collapsed or repositioned on smaller screens
+- **Easier Maintenance**: Legend updates only need to happen in one place
+
+### Implementation Code
+
+\`\`\`jsx
+// Charts with hidden legends
+
+
+
+
+// Centralized legend panel
+
+\`\`\`
+`,
+ },
+ },
+ },
+};
+
+// Story showing different alignment options
+export const AlignmentOptions: Story = {
args: {
items: [
- { label: 'Very Long Desktop Usage', value: '86%', color: '#3858E9' },
- { label: 'Extended Mobile Sessions', value: '52%', color: '#80C8FF' },
- { label: 'Tablet Device Access', value: '35%', color: '#44B556' },
+ { label: 'Series 1', value: '25%', color: '#3858E9' },
+ { label: 'Series 2', value: '35%', color: '#80C8FF' },
+ { label: 'Series 3', value: '40%', color: '#44B556' },
],
orientation: 'horizontal',
+ alignmentHorizontal: 'left',
+ alignmentVertical: 'top',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Legend with custom alignment options.',
+ },
+ },
+ },
+};
+
+// Story showing the legend with custom shapes
+export const CustomShape: Story = {
+ args: {
+ items: [
+ { label: 'Desktop', value: '65%', color: '#3858E9' },
+ { label: 'Mobile', value: '35%', color: '#80C8FF' },
+ ],
+ orientation: 'horizontal',
+ shape: 'circle',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Legend with circle shape instead of default rectangle.',
+ },
+ },
},
};
diff --git a/projects/js-packages/charts/src/components/legend/types.ts b/projects/js-packages/charts/src/components/legend/types.ts
index 4855926d248c9..7352ecd93c65e 100644
--- a/projects/js-packages/charts/src/components/legend/types.ts
+++ b/projects/js-packages/charts/src/components/legend/types.ts
@@ -24,9 +24,14 @@ export type LegendItemWithoutGlyph = BaseLegendItem & {
glyphSize?: number;
};
-export type LegendProps = Omit< LegendOrdinalProps, 'shapeStyle' > & {
+export type BaseLegendProps = Omit< LegendOrdinalProps, 'shapeStyle' > & {
items: LegendItemWithGlyph[] | LegendItemWithoutGlyph[];
orientation?: 'horizontal' | 'vertical';
alignmentHorizontal?: 'left' | 'center' | 'right';
alignmentVertical?: 'top' | 'bottom';
};
+
+export type LegendProps = Omit< BaseLegendProps, 'items' > & {
+ items?: LegendItemWithGlyph[] | LegendItemWithoutGlyph[];
+ chartId?: string;
+};
diff --git a/projects/js-packages/charts/src/components/legend/use-chart-legend-data.ts b/projects/js-packages/charts/src/components/legend/use-chart-legend-data.ts
new file mode 100644
index 0000000000000..9438ce28c9028
--- /dev/null
+++ b/projects/js-packages/charts/src/components/legend/use-chart-legend-data.ts
@@ -0,0 +1,175 @@
+import { useMemo } from 'react';
+import type { LegendItemWithGlyph, LegendItemWithoutGlyph } from './types';
+import type { ChartTheme, SeriesData, DataPointDate, DataPointPercentage } from '../../types';
+
+export interface ChartLegendOptions {
+ withGlyph?: boolean;
+ glyphSize?: number;
+ renderGlyph?: React.ComponentType< unknown >;
+ showValues?: boolean;
+}
+
+/**
+ * Formats the value for a data point based on its type
+ * @param point - The data point to format
+ * @param showValues - Whether to show values or return empty string
+ * @return Formatted value string
+ */
+function formatPointValue(
+ point: DataPointDate | DataPointPercentage,
+ showValues: boolean
+): string {
+ if ( ! showValues ) {
+ return '';
+ }
+
+ if ( 'percentage' in point ) {
+ return `${ point.percentage }%`;
+ } else if ( 'value' in point ) {
+ return point.value.toString();
+ }
+
+ return '';
+}
+
+/**
+ * Creates a base legend item with common properties
+ * @param label - The label for the legend item
+ * @param value - The value for the legend item
+ * @param color - The color for the legend item
+ * @return Base legend item object
+ */
+function createBaseLegendItem(
+ label: string,
+ value: string,
+ color: string
+): Omit< LegendItemWithGlyph, 'glyphSize' | 'renderGlyph' > {
+ return {
+ label,
+ value,
+ color,
+ };
+}
+
+/**
+ * Processes SeriesData into legend items
+ * @param seriesData - The series data to process
+ * @param theme - The chart theme for colors
+ * @param showValues - Whether to show values in legend
+ * @param withGlyph - Whether to include glyph rendering
+ * @param glyphSize - Size of the glyph
+ * @param renderGlyph - Component to render the glyph
+ * @return Array of processed legend items
+ */
+function processSeriesData(
+ seriesData: SeriesData[],
+ theme: ChartTheme,
+ showValues: boolean,
+ withGlyph: boolean,
+ glyphSize: number,
+ renderGlyph?: React.ComponentType< unknown >
+): LegendItemWithGlyph[] | LegendItemWithoutGlyph[] {
+ const mapper = ( series: SeriesData, index: number ) => {
+ const baseItem = createBaseLegendItem(
+ series.label,
+ showValues ? series.data?.length?.toString() || '0' : '',
+ theme.colors[ index % theme.colors.length ]
+ );
+
+ if ( withGlyph && renderGlyph ) {
+ return {
+ ...baseItem,
+ glyphSize,
+ renderGlyph,
+ } as LegendItemWithGlyph;
+ }
+
+ return baseItem as LegendItemWithoutGlyph;
+ };
+
+ return seriesData.map( mapper ) as LegendItemWithGlyph[] | LegendItemWithoutGlyph[];
+}
+
+/**
+ * Processes point data into legend items
+ * @param pointData - The point data to process
+ * @param theme - The chart theme for colors
+ * @param showValues - Whether to show values in legend
+ * @param withGlyph - Whether to include glyph rendering
+ * @param glyphSize - Size of the glyph
+ * @param renderGlyph - Component to render the glyph
+ * @return Array of processed legend items
+ */
+function processPointData(
+ pointData: ( DataPointDate | DataPointPercentage )[],
+ theme: ChartTheme,
+ showValues: boolean,
+ withGlyph: boolean,
+ glyphSize: number,
+ renderGlyph?: React.ComponentType< unknown >
+): LegendItemWithGlyph[] | LegendItemWithoutGlyph[] {
+ const mapper = ( point: DataPointDate | DataPointPercentage, index: number ) => {
+ const baseItem = createBaseLegendItem(
+ point.label,
+ formatPointValue( point, showValues ),
+ theme.colors[ index % theme.colors.length ]
+ );
+
+ if ( withGlyph && renderGlyph ) {
+ return {
+ ...baseItem,
+ glyphSize,
+ renderGlyph,
+ } as LegendItemWithGlyph;
+ }
+
+ return baseItem as LegendItemWithoutGlyph;
+ };
+
+ return pointData.map( mapper ) as LegendItemWithGlyph[] | LegendItemWithoutGlyph[];
+}
+
+/**
+ * Hook to transform chart data into legend items
+ * @param data - The chart data to transform
+ * @param theme - The chart theme for colors
+ * @param options - Configuration options for legend generation
+ * @return Array of legend items ready for display
+ */
+export function useChartLegendData<
+ T extends SeriesData[] | DataPointDate[] | DataPointPercentage[],
+>(
+ data: T,
+ theme: ChartTheme,
+ options: ChartLegendOptions = {}
+): LegendItemWithGlyph[] | LegendItemWithoutGlyph[] {
+ const { showValues = false, withGlyph = false, glyphSize = 8, renderGlyph } = options;
+
+ return useMemo( () => {
+ if ( ! data || ! Array.isArray( data ) || data.length === 0 ) {
+ return [];
+ }
+
+ // Handle SeriesData (multiple series with data points)
+ if ( 'data' in data[ 0 ] ) {
+ return processSeriesData(
+ data as SeriesData[],
+ theme,
+ showValues,
+ withGlyph,
+ glyphSize,
+ renderGlyph
+ );
+ }
+
+ // Handle DataPointDate or DataPointPercentage (single data points)
+ return processPointData(
+ data as ( DataPointDate | DataPointPercentage )[],
+ theme,
+ showValues,
+ withGlyph,
+ glyphSize,
+ renderGlyph
+ );
+ }, [ data, theme, showValues, withGlyph, glyphSize, renderGlyph ] );
+}
diff --git a/projects/js-packages/charts/src/components/line-chart/line-chart.tsx b/projects/js-packages/charts/src/components/line-chart/line-chart.tsx
index 2889dcdb33630..663f89f361685 100644
--- a/projects/js-packages/charts/src/components/line-chart/line-chart.tsx
+++ b/projects/js-packages/charts/src/components/line-chart/line-chart.tsx
@@ -12,9 +12,15 @@ import {
useState,
useRef,
} from 'react';
-import { ChartProvider, useChartId, useChartRegistration } from '../../providers/chart-context';
+import {
+ ChartProvider,
+ ChartContext,
+ useChartId,
+ useChartRegistration,
+} from '../../providers/chart-context';
import { useXYChartTheme, useChartTheme } from '../../providers/theme/theme-provider';
import { Legend } from '../legend';
+import { useChartLegendData } from '../legend/use-chart-legend-data';
import { DefaultGlyph } from '../shared/default-glyph';
import { useChartDataTransform } from '../shared/use-chart-data-transform';
import { useChartMargin } from '../shared/use-chart-margin';
@@ -332,36 +338,33 @@ const LineChartInternal = forwardRef< LineChartRef, LineChartProps >(
const error = validateData( dataSorted );
const isDataValid = ! error;
- // Create legend items (hooks must be called in same order every render)
- const legendItems = useMemo(
- () =>
- dataSorted.map( ( group, index ) => ( {
- label: group.label, // Label for each unique group
- value: '', // Empty string since we don't want to show a specific value
- color:
- group?.options?.stroke ?? providerTheme.colors[ index % providerTheme.colors.length ],
- shapeStyle: group?.options?.legendShapeStyle,
- renderGlyph: withLegendGlyph ? providerTheme.glyphs?.[ index ] ?? renderGlyph : undefined,
- glyphSize: Math.max( 0, toNumber( glyphStyle?.radius ) ?? 4 ),
- } ) ),
- [
- dataSorted,
- providerTheme.colors,
- providerTheme.glyphs,
- withLegendGlyph,
+ // Memoize legend options to prevent unnecessary re-calculations
+ const legendOptions = useMemo(
+ () => ( {
+ withGlyph: withLegendGlyph,
+ glyphSize: Math.max( 0, toNumber( glyphStyle?.radius ) ?? 4 ),
renderGlyph,
- glyphStyle?.radius,
- ]
+ } ),
+ [ withLegendGlyph, glyphStyle?.radius, renderGlyph ]
+ );
+
+ // Create legend items using the reusable hook
+ const legendItems = useChartLegendData( dataSorted, providerTheme, legendOptions );
+
+ // Memoize metadata to prevent unnecessary re-registration
+ const chartMetadata = useMemo(
+ () => ( {
+ withGradientFill,
+ smoothing,
+ curveType,
+ withStartGlyphs,
+ withLegendGlyph,
+ } ),
+ [ withGradientFill, smoothing, curveType, withStartGlyphs, withLegendGlyph ]
);
// Register chart with context only if data is valid
- useChartRegistration( chartId, legendItems, providerTheme, 'line', isDataValid, {
- withGradientFill,
- smoothing,
- curveType,
- withStartGlyphs,
- withLegendGlyph,
- } );
+ useChartRegistration( chartId, legendItems, providerTheme, 'line', isDataValid, chartMetadata );
const accessors = {
xAccessor: ( d: DataPointDate ) => d?.date,
@@ -514,6 +517,7 @@ const LineChartInternal = forwardRef< LineChartRef, LineChartProps >(
alignmentVertical={ legendAlignmentVertical }
className={ styles[ 'line-chart-legend' ] }
shape={ legendShape }
+ chartId={ chartId }
ref={ legendRef }
/>
) }
@@ -542,11 +546,21 @@ type LineChartResponsiveComponent = React.ForwardRefExoticComponent<
> &
LineChartAnnotationComponents;
-const LineChart = forwardRef< LineChartRef, LineChartProps >( ( props, ref ) => (
-
-
-
-) ) as LineChartComponent;
+const LineChart = forwardRef< LineChartRef, LineChartProps >( ( props, ref ) => {
+ const existingContext = useContext( ChartContext );
+
+ // If we're already in a ChartProvider context, don't create a new one
+ if ( existingContext ) {
+ return ;
+ }
+
+ // Otherwise, create our own ChartProvider
+ return (
+
+
+
+ );
+} ) as LineChartComponent;
LineChart.displayName = 'LineChart';
LineChart.AnnotationsOverlay = LineChartAnnotationsOverlay;
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 47c37a57e2d7c..9fd1bbda41273 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
@@ -1,4 +1,5 @@
.pie-chart {
display: flex;
flex-direction: column;
+ overflow: hidden;
}
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 ee2151a398d48..9f62856e5b7f3 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
@@ -1,11 +1,13 @@
import { Group } from '@visx/group';
import { Pie } from '@visx/shape';
import clsx from 'clsx';
-import { useMemo } from 'react';
+import { useContext, useMemo } from 'react';
import useChartMouseHandler from '../../hooks/use-chart-mouse-handler';
import { ChartProvider, useChartId, useChartRegistration } from '../../providers/chart-context';
+import { ChartContext } from '../../providers/chart-context/chart-context';
import { useChartTheme, defaultTheme } from '../../providers/theme';
import { Legend } from '../legend';
+import { useChartLegendData } from '../legend/use-chart-legend-data';
import { useElementHeight } from '../shared/use-element-height';
import { withResponsive } from '../shared/with-responsive';
import { BaseTooltip } from '../tooltip';
@@ -13,9 +15,7 @@ import styles from './pie-chart.module.scss';
import type { BaseChartProps, DataPointPercentage } from '../../types';
import type { SVGProps, MouseEvent, ReactNode } from 'react';
-type OmitBaseChartProps = Omit< BaseChartProps< DataPointPercentage[] >, 'width' | 'height' >;
-
-interface PieChartProps extends OmitBaseChartProps {
+interface PieChartProps extends BaseChartProps< DataPointPercentage[] > {
/**
* Inner radius in pixels. If > 0, creates a donut chart. Defaults to 0.
*/
@@ -108,25 +108,26 @@ const PieChartInternal = ( {
withTooltips,
} );
+ // Memoize legend options to prevent unnecessary re-calculations
+ const legendOptions = useMemo( () => ( { showValues: true } ), [] );
+
+ // Create legend items using the reusable hook
+ const legendItems = useChartLegendData( data, providerTheme, legendOptions );
+
const { isValid, message } = validateData( data );
- // Create legend items (hooks must be called in same order every render)
- const legendItems = useMemo(
- () =>
- data.map( ( item, index ) => ( {
- label: item.label,
- value: item.value.toString(),
- color: providerTheme.colors[ index % providerTheme.colors.length ],
- } ) ),
- [ data, providerTheme.colors ]
+ // Memoize metadata to prevent unnecessary re-registration
+ const chartMetadata = useMemo(
+ () => ( {
+ thickness,
+ gapScale,
+ cornerScale,
+ } ),
+ [ thickness, gapScale, cornerScale ]
);
// Register chart with context only if data is valid
- useChartRegistration( chartId, legendItems, providerTheme, 'pie', isValid, {
- thickness,
- gapScale,
- cornerScale,
- } );
+ useChartRegistration( chartId, legendItems, providerTheme, 'pie', isValid, chartMetadata );
if ( ! isValid ) {
return (
@@ -247,6 +248,7 @@ const PieChartInternal = ( {
className={ styles[ 'pie-chart-legend' ] }
shape={ legendShape }
ref={ legendRef }
+ chartId={ chartId }
/>
) }
@@ -264,11 +266,21 @@ const PieChartInternal = ( {
);
};
-const PieChart = ( props: PieChartProps ) => (
-
-
-
-);
+const PieChart = ( props: PieChartProps ) => {
+ const existingContext = useContext( ChartContext );
+
+ // If we're already in a ChartProvider context, don't create a new one
+ if ( existingContext ) {
+ return ;
+ }
+
+ // Otherwise, create our own ChartProvider
+ return (
+
+
+
+ );
+};
PieChart.displayName = 'PieChart';
export default withResponsive< PieChartProps >( PieChart );
diff --git a/projects/js-packages/charts/src/components/pie-chart/test/pie-chart.test.tsx b/projects/js-packages/charts/src/components/pie-chart/test/pie-chart.test.tsx
index fcdfbb2d8e3f7..1958dbaa2f6f3 100644
--- a/projects/js-packages/charts/src/components/pie-chart/test/pie-chart.test.tsx
+++ b/projects/js-packages/charts/src/components/pie-chart/test/pie-chart.test.tsx
@@ -18,7 +18,6 @@ describe( 'PieChart', () => {
const renderWithTheme = ( props = {} ) => {
return render(
- { /* @ts-expect-error TODO Fix the missing props */ }
);
diff --git a/projects/js-packages/charts/src/components/pie-semi-circle-chart/pie-semi-circle-chart.tsx b/projects/js-packages/charts/src/components/pie-semi-circle-chart/pie-semi-circle-chart.tsx
index ee6ec3efe2421..94ba256ea1e61 100644
--- a/projects/js-packages/charts/src/components/pie-semi-circle-chart/pie-semi-circle-chart.tsx
+++ b/projects/js-packages/charts/src/components/pie-semi-circle-chart/pie-semi-circle-chart.tsx
@@ -136,7 +136,7 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
[ providerTheme.colors ]
);
- // Create legend items (hooks must be called in same order every render)
+ // Create legend items with color from accessors (which respects item.color)
const legendItems = useMemo(
() =>
data.map( ( item, index ) => ( {
@@ -147,11 +147,24 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
[ data, accessors ]
);
+ // Memoize metadata to prevent unnecessary re-registration
+ const chartMetadata = useMemo(
+ () => ( {
+ thickness,
+ clockwise,
+ } ),
+ [ thickness, clockwise ]
+ );
+
// Register chart with context only if data is valid
- useChartRegistration( chartId, legendItems, providerTheme, 'pie-semi-circle', isValid, {
- thickness,
- clockwise,
- } );
+ useChartRegistration(
+ chartId,
+ legendItems,
+ providerTheme,
+ 'pie-semi-circle',
+ isValid,
+ chartMetadata
+ );
if ( ! isValid ) {
return (
@@ -184,7 +197,6 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
// Set the clockwise direction based on the prop
const startAngle = clockwise ? -Math.PI / 2 : Math.PI / 2;
const endAngle = clockwise ? Math.PI / 2 : -Math.PI / 2;
-
return (