diff --git a/projects/js-packages/charts/changelog/add-charts-50-standalone-legend-component b/projects/js-packages/charts/changelog/add-charts-50-standalone-legend-component new file mode 100644 index 0000000000000..150649e8cac6e --- /dev/null +++ b/projects/js-packages/charts/changelog/add-charts-50-standalone-legend-component @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Charts: adds a standalone chart legend component diff --git a/projects/js-packages/charts/src/components/bar-chart/bar-chart.tsx b/projects/js-packages/charts/src/components/bar-chart/bar-chart.tsx index 033a0c6faf499..3a25aebe86292 100644 --- a/projects/js-packages/charts/src/components/bar-chart/bar-chart.tsx +++ b/projects/js-packages/charts/src/components/bar-chart/bar-chart.tsx @@ -1,10 +1,12 @@ import { PatternLines, PatternCircles, PatternWaves, PatternHexagons } from '@visx/pattern'; import { Axis, BarSeries, BarGroup, Grid, XYChart } from '@visx/xychart'; import clsx from 'clsx'; -import { useCallback, useId, useState, useRef, useMemo } from 'react'; +import { useCallback, useContext, useId, useState, useRef, useMemo } from 'react'; import { ChartProvider, useChartId, useChartRegistration } from '../../providers/chart-context'; +import { ChartContext } from '../../providers/chart-context/chart-context'; import { useChartTheme, useXYChartTheme } from '../../providers/theme'; import { Legend } from '../legend'; +import { useChartLegendData } from '../legend/use-chart-legend-data'; import { useChartDataTransform } from '../shared/use-chart-data-transform'; import { useChartMargin } from '../shared/use-chart-margin'; import { useElementHeight } from '../shared/use-element-height'; @@ -66,10 +68,14 @@ const BarChartInternal: FC< BarChartProps > = ( { // Generate a unique chart ID to avoid pattern conflicts with multiple charts const internalChartId = useId(); const chartId = useChartId( providedChartId ); + const providerTheme = useChartTheme(); const theme = useXYChartTheme( data ); const dataSorted = useChartDataTransform( data ); + // Create legend items using the reusable hook + const legendItems = useChartLegendData( dataSorted, providerTheme ); + const chartOptions = useBarChartOptions( dataSorted, horizontal, options ); const defaultMargin = useChartMargin( height, chartOptions, dataSorted, theme, horizontal ); const [ legendRef, legendHeight ] = useElementHeight< HTMLDivElement >(); @@ -222,24 +228,17 @@ const BarChartInternal: FC< BarChartProps > = ( { 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: getColor( group, index ), - shapeStyle: group?.options?.legendShapeStyle, - } ) ), - [ dataSorted, getColor ] + // Memoize metadata to prevent unnecessary re-registration + const chartMetadata = useMemo( + () => ( { + orientation, + withPatterns, + } ), + [ orientation, withPatterns ] ); // Register chart with context only if data is valid - const providerTheme = useChartTheme(); - useChartRegistration( chartId, legendItems, providerTheme, 'bar', isDataValid, { - orientation, - withPatterns, - } ); + useChartRegistration( chartId, legendItems, providerTheme, 'bar', isDataValid, chartMetadata ); if ( error ) { return
{ error }
; @@ -347,17 +346,28 @@ const BarChartInternal: FC< BarChartProps > = ( { className={ styles[ 'bar-chart__legend' ] } shape={ legendShape } ref={ legendRef } + chartId={ chartId } /> ) } ); }; -const BarChart: FC< BarChartProps > = props => ( - - - -); +const BarChart: FC< BarChartProps > = props => { + 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 ( + + + + ); +}; BarChart.displayName = 'BarChart'; diff --git a/projects/js-packages/charts/src/components/legend/base-legend.tsx b/projects/js-packages/charts/src/components/legend/base-legend.tsx index 422615d390c7c..eb87ed8c1e042 100644 --- a/projects/js-packages/charts/src/components/legend/base-legend.tsx +++ b/projects/js-packages/charts/src/components/legend/base-legend.tsx @@ -6,7 +6,7 @@ import { forwardRef, useCallback } from 'react'; import { useChartTheme } from '../../providers/theme'; import styles from './legend.module.scss'; import { valueOrIdentity, valueOrIdentityString, labelTransformFactory } from './utils'; -import type { LegendProps } from './types'; +import type { BaseLegendProps } from './types'; const orientationToFlexDirection = { horizontal: 'row' as const, @@ -17,7 +17,7 @@ const orientationToFlexDirection = { * Base legend component that displays color-coded items with labels based on visx LegendOrdinal. * We avoid using LegendOrdinal directly to enable support for advanced features such as interactivity. */ -export const BaseLegend = forwardRef< HTMLDivElement, LegendProps >( +export const BaseLegend = forwardRef< HTMLDivElement, BaseLegendProps >( ( { items, diff --git a/projects/js-packages/charts/src/components/legend/index.ts b/projects/js-packages/charts/src/components/legend/index.ts index a7609da9a205f..cc9e9d25c3abb 100644 --- a/projects/js-packages/charts/src/components/legend/index.ts +++ b/projects/js-packages/charts/src/components/legend/index.ts @@ -1,2 +1,5 @@ -export { BaseLegend as Legend } from './base-legend'; -export type { LegendProps } from './types'; +export { Legend } from './legend'; +export { BaseLegend } from './base-legend'; +export { useChartLegendData } from './use-chart-legend-data'; +export type { LegendProps, BaseLegendProps } from './types'; +export type { ChartLegendOptions } from './use-chart-legend-data'; diff --git a/projects/js-packages/charts/src/components/legend/legend.tsx b/projects/js-packages/charts/src/components/legend/legend.tsx new file mode 100644 index 0000000000000..1fa366c0e2ade --- /dev/null +++ b/projects/js-packages/charts/src/components/legend/legend.tsx @@ -0,0 +1,25 @@ +import { useContext, useMemo, forwardRef } from 'react'; +import { ChartContext } from '../../providers/chart-context/chart-context'; +import { BaseLegend } from './base-legend'; +import type { LegendProps } from './types'; + +export const Legend = forwardRef< HTMLDivElement, LegendProps >( + ( { chartId, items, ...props }, ref ) => { + // Get context but don't throw if it doesn't exist + const context = useContext( ChartContext ); + + // Use useMemo to ensure re-rendering when context changes + const contextItems = useMemo( () => { + return chartId && context ? context.getChartData( chartId )?.legendItems : undefined; + }, [ chartId, context ] ); + + // Use context items if available, otherwise fall back to provided items + const legendItems = ( contextItems || items ) as typeof items; + + if ( ! legendItems ) { + return null; + } + + return ; + } +); diff --git a/projects/js-packages/charts/src/components/legend/stories/index.stories.tsx b/projects/js-packages/charts/src/components/legend/stories/index.stories.tsx index 50b9f6c79eb37..1681db064ee82 100644 --- a/projects/js-packages/charts/src/components/legend/stories/index.stories.tsx +++ b/projects/js-packages/charts/src/components/legend/stories/index.stories.tsx @@ -1,49 +1,478 @@ import { Meta, StoryObj } from '@storybook/react'; -import { BaseLegend } from '../base-legend'; +import { ChartProvider } from '../../../providers/chart-context'; +import { useChartTheme } from '../../../providers/theme'; +import { BarChart } from '../../bar-chart'; +import { LineChart } from '../../line-chart'; +import { PieChart } from '../../pie-chart'; +import { Legend } from '../legend'; +import { useChartLegendData } from '../use-chart-legend-data'; +import type { SeriesData, DataPointPercentage } from '../../../types'; -const meta: Meta< typeof BaseLegend > = { +const meta: Meta< typeof Legend > = { title: 'JS Packages/Charts/Composites/Legend', - component: BaseLegend, + component: Legend, parameters: { layout: 'centered', docs: { description: { - component: - 'A flexible legend component that can be customized with different styles and orientations.', + component: ` +The Legend component provides a flexible way to display chart legends either as standalone components or integrated with charts through the chart context. + +## Key Features + +- **Standalone Usage**: Display legends independently from charts +- **Context Integration**: Automatically retrieve legend data from charts using \`chartId\` +- **Flexible Positioning**: Place legends anywhere in your layout +- **Works with Hidden Legends**: Charts with \`showLegend={false}\` still provide data to standalone legends +- **Full Customization**: Inherits all props from BaseLegend for complete control + +## Usage Examples + +### Basic Usage with Manual Data +\`\`\`jsx + +\`\`\` + +### Automatic Data from Chart Context +\`\`\`jsx +// Chart registers its legend data with chartId +