diff --git a/projects/js-packages/charts/changelog/add-charts-54-legends-toggling-visibility-of-series b/projects/js-packages/charts/changelog/add-charts-54-legends-toggling-visibility-of-series new file mode 100644 index 0000000000000..0db5a3582dc74 --- /dev/null +++ b/projects/js-packages/charts/changelog/add-charts-54-legends-toggling-visibility-of-series @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Charts: add legend interactivity diff --git a/projects/js-packages/charts/src/components/legend/legend.tsx b/projects/js-packages/charts/src/components/legend/legend.tsx index 324fdb00a64a6..cc4cadeaec247 100644 --- a/projects/js-packages/charts/src/components/legend/legend.tsx +++ b/projects/js-packages/charts/src/components/legend/legend.tsx @@ -28,6 +28,6 @@ export const Legend = forwardRef< HTMLDivElement, LegendProps >( return null; } - return ; + return ; } ); diff --git a/projects/js-packages/charts/src/components/legend/private/base-legend.module.scss b/projects/js-packages/charts/src/components/legend/private/base-legend.module.scss index bf256ac573896..67176762db075 100644 --- a/projects/js-packages/charts/src/components/legend/private/base-legend.module.scss +++ b/projects/js-packages/charts/src/components/legend/private/base-legend.module.scss @@ -63,6 +63,34 @@ display: flex; align-items: center; font-size: 0.875rem; + + &--interactive { + cursor: pointer; + user-select: none; + transition: opacity 0.2s ease; + + &:hover { + opacity: 0.8; + } + + &:focus { + outline: 2px solid currentColor; + outline-offset: 2px; + border-radius: 4px; + } + + &:focus:not(:focus-visible) { + outline: none; + } + } + + &--inactive { + opacity: 0.4; + + .legend-item-label { + text-decoration: line-through; + } + } } .legend-item-label { 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 b684122f9a638..31e2b1a97e80b 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 @@ -2,9 +2,16 @@ import { Group } from '@visx/group'; import { LegendItem, LegendLabel, LegendOrdinal, LegendShape } from '@visx/legend'; import { scaleOrdinal } from '@visx/scale'; import clsx from 'clsx'; -import { type RefAttributes, type ForwardRefExoticComponent, forwardRef, useCallback } from 'react'; +import { + type RefAttributes, + type ForwardRefExoticComponent, + type KeyboardEvent, + forwardRef, + useCallback, + useContext, +} from 'react'; import { useTextTruncation } from '../../../hooks'; -import { useGlobalChartsTheme } from '../../../providers'; +import { GlobalChartsContext, useGlobalChartsTheme } from '../../../providers'; import { valueOrIdentity, valueOrIdentityString, labelTransformFactory } from '../utils'; import styles from './base-legend.module.scss'; import type { BaseLegendProps } from '../types'; @@ -80,11 +87,14 @@ export const BaseLegend: ForwardRefExoticComponent< legendLabelProps, legendItemClassName, render, + interactive = false, + chartId, ...legendItemProps }, ref ) => { const theme = useGlobalChartsTheme(); + const context = useContext( GlobalChartsContext ); const legendScale = scaleOrdinal( { domain: items.map( item => item.label ), @@ -97,6 +107,53 @@ export const BaseLegend: ForwardRefExoticComponent< [ items ] ); + // Handle legend item clicks for interactive mode + const handleLegendClick = useCallback( + ( seriesLabel: string ) => { + if ( interactive && chartId && context ) { + context.toggleSeriesVisibility( chartId, seriesLabel ); + } + }, + [ interactive, chartId, context ] + ); + + // Check if a series is visible + const isSeriesVisible = useCallback( + ( seriesLabel: string ) => { + if ( ! interactive || ! chartId || ! context ) { + return true; + } + return context.isSeriesVisible( chartId, seriesLabel ); + }, + [ interactive, chartId, context ] + ); + + // Create event handlers to avoid inline arrow functions + const createClickHandler = useCallback( + ( labelText: string ) => { + if ( ! interactive ) { + return undefined; + } + return () => handleLegendClick( labelText ); + }, + [ interactive, handleLegendClick ] + ); + + const createKeyDownHandler = useCallback( + ( labelText: string ) => { + if ( ! interactive ) { + return undefined; + } + return ( event: KeyboardEvent ) => { + if ( event.key === 'Enter' || event.key === ' ' ) { + event.preventDefault(); + handleLegendClick( labelText ); + } + }; + }, + [ interactive, handleLegendClick ] + ); + return render ? ( render( items ) ) : ( @@ -122,77 +179,97 @@ export const BaseLegend: ForwardRefExoticComponent< ...theme.legendContainerStyles, } } > - { labels.map( ( label, i ) => ( - - { items[ i ]?.renderGlyph ? ( - - - { items[ i ]?.renderGlyph( { - key: `legend-glyph-${ label.text }`, - datum: {}, - index: i, - color: fill( label ), - size: items[ i ]?.glyphSize, - x: items[ i ]?.glyphSize, - y: items[ i ]?.glyphSize, - } ) } - - - ) : ( - - ) } - { + const visible = isSeriesVisible( label.text ); + const handleClick = createClickHandler( label.text ); + const handleKeyDown = createKeyDownHandler( label.text ); + + return ( + - - { items.find( item => item.label === label.text )?.value && ( - - { '\u00A0' } - { items.find( item => item.label === label.text )?.value } - + { items[ i ]?.renderGlyph ? ( + + + { items[ i ]?.renderGlyph( { + key: `legend-glyph-${ label.text }`, + datum: {}, + index: i, + color: fill( label ), + size: items[ i ]?.glyphSize, + x: items[ i ]?.glyphSize, + y: items[ i ]?.glyphSize, + } ) } + + + ) : ( + ) } - - - ) ) } + + + { items.find( item => item.label === label.text )?.value && ( + + { '\u00A0' } + { items.find( item => item.label === label.text )?.value } + + ) } + + + ); + } ) } ) } diff --git a/projects/js-packages/charts/src/components/legend/stories/index.api.mdx b/projects/js-packages/charts/src/components/legend/stories/index.api.mdx index eea7c3ab11b80..674c6fa80d470 100644 --- a/projects/js-packages/charts/src/components/legend/stories/index.api.mdx +++ b/projects/js-packages/charts/src/components/legend/stories/index.api.mdx @@ -38,6 +38,7 @@ The Legend component displays chart legends with support for both standalone usa | `itemMargin` | `string \| number` | `'0'` | Margin around each legend item. | | `itemDirection` | `'row' \| 'column'` | `'row'` | Flex direction for items within each legend entry. | | `legendLabelProps` | `object` | - | Additional props to pass to the underlying LegendLabel component from visx. | +| `interactive` | `boolean` | `false` | Enable interactive legend items that can toggle series visibility. Currently only supported for LineChart. Requires `chartId` and `GlobalChartsProvider`. | ## BaseLegendItem Type diff --git a/projects/js-packages/charts/src/components/legend/stories/index.docs.mdx b/projects/js-packages/charts/src/components/legend/stories/index.docs.mdx index b1f848070f327..c1783a4afac9f 100644 --- a/projects/js-packages/charts/src/components/legend/stories/index.docs.mdx +++ b/projects/js-packages/charts/src/components/legend/stories/index.docs.mdx @@ -290,6 +290,73 @@ Custom glyphs should be centered at the provided `x` and `y` coordinates. The gl - Text overflow features require both `maxWidth` and `textOverflow` props to be set - The component uses visx LegendOrdinal internally but provides a simplified, more flexible API +## Interactive Legends + +Interactive legends allow users to toggle series visibility by clicking or using keyboard navigation. This feature is currently supported for LineChart only. + +### Basic Interactive Legend + +Enable interactive legends by setting the `interactive` prop on the chart. The Legend component will automatically become interactive when connected via `chartId`: + + + + ` } +/> + +### Standalone Interactive Legend + +Interactive legends also work with standalone Legend components when using `chartId`: + + + + + + ` } +/> + +### Features + +- **Click/Tap Interaction**: Toggle series visibility by clicking on legend items +- **Keyboard Support**: Use Tab to focus items, Enter or Space to toggle +- **Visual Feedback**: Hidden series appear with reduced opacity and strikethrough text +- **Color Stability**: Series colors remain consistent when toggling visibility +- **Accessibility**: Full ARIA support with `role="button"`, `aria-pressed`, and descriptive `aria-label` + +### Requirements + +Interactive legends require: + +1. **GlobalChartsProvider**: Must wrap both chart and legend +2. **chartId**: A unique identifier to connect chart and legend +3. **interactive prop**: Set to `true` on the chart component +4. **Chart Support**: Currently only LineChart supports interactive legends + +### Current Limitations + +- Interactive legends are only supported for LineChart +- Support for other chart types (BarChart, PieChart, etc.) will be added in future releases +- The `render` prop is not compatible with interactive legends (interactive mode will be disabled) + ## Accessibility ### Semantic HTML @@ -302,4 +369,8 @@ When using ellipsis mode, truncated text automatically includes a `title` attrib ### Keyboard Navigation -Legend items are rendered as standard DOM elements and follow native accessibility patterns. For interactive legends (future feature), keyboard navigation will follow ARIA best practices. +Legend items are rendered as standard DOM elements and follow native accessibility patterns. When using interactive legends, keyboard navigation follows ARIA best practices: + +- **Tab**: Focus individual legend items +- **Enter/Space**: Toggle series visibility +- **Screen Reader**: Announces current visibility state ("visible" or "hidden") diff --git a/projects/js-packages/charts/src/components/legend/test/legend.test.tsx b/projects/js-packages/charts/src/components/legend/test/legend.test.tsx index b5d7f64bc723f..a56ec7846cb2e 100644 --- a/projects/js-packages/charts/src/components/legend/test/legend.test.tsx +++ b/projects/js-packages/charts/src/components/legend/test/legend.test.tsx @@ -1,5 +1,7 @@ /* eslint-disable react/jsx-no-bind */ import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { GlobalChartsProvider } from '../../../providers'; import { BaseLegend } from '../private/base-legend'; import type { LegendProps } from '../types'; @@ -326,4 +328,121 @@ describe( 'BaseLegend', () => { expect( screen.queryByTestId( 'legend-vertical' ) ).not.toBeInTheDocument(); } ); } ); + + describe( 'Interactive legend', () => { + it( 'renders interactive legend items with proper attributes', () => { + render( + + + + ); + + const legendItems = screen.getAllByRole( 'button' ); + expect( legendItems ).toHaveLength( 2 ); + expect( legendItems[ 0 ] ).toHaveAttribute( 'tabIndex', '0' ); + expect( legendItems[ 0 ] ).toHaveAttribute( 'aria-pressed', 'true' ); + } ); + + it( 'handles click events to toggle visibility', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const legendItems = screen.getAllByRole( 'button' ); + + // Click to toggle + await user.click( legendItems[ 0 ] ); + + // After click, the aria-pressed should change + expect( legendItems[ 0 ] ).toHaveAttribute( 'aria-pressed', 'false' ); + } ); + + it( 'handles Enter key to toggle visibility', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const legendItems = screen.getAllByRole( 'button' ); + legendItems[ 0 ].focus(); + + // Press Enter + await user.keyboard( '{Enter}' ); + + expect( legendItems[ 0 ] ).toHaveAttribute( 'aria-pressed', 'false' ); + } ); + + it( 'handles Space key to toggle visibility', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const legendItems = screen.getAllByRole( 'button' ); + legendItems[ 1 ].focus(); + + // Press Space + await user.keyboard( ' ' ); + + expect( legendItems[ 1 ] ).toHaveAttribute( 'aria-pressed', 'false' ); + } ); + + it( 'does not toggle on non-action keys', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const legendItems = screen.getAllByRole( 'button' ); + legendItems[ 0 ].focus(); + + // Press a random key + await user.keyboard( 'a' ); + + // Should remain pressed (visible) + expect( legendItems[ 0 ] ).toHaveAttribute( 'aria-pressed', 'true' ); + } ); + + it( 'renders non-interactive legend when interactive is false', () => { + render( + + + + ); + + const buttons = screen.queryAllByRole( 'button' ); + expect( buttons ).toHaveLength( 0 ); + } ); + + it( 'works without chartId but does not toggle', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const legendItems = screen.getAllByRole( 'button' ); + + // Click should not change state without chartId + await user.click( legendItems[ 0 ] ); + + // Should still be visible (pressed) + expect( legendItems[ 0 ] ).toHaveAttribute( 'aria-pressed', 'true' ); + } ); + } ); } ); diff --git a/projects/js-packages/charts/src/components/legend/types.ts b/projects/js-packages/charts/src/components/legend/types.ts index 07bc3884990e4..5a0a2a684cce8 100644 --- a/projects/js-packages/charts/src/components/legend/types.ts +++ b/projects/js-packages/charts/src/components/legend/types.ts @@ -33,6 +33,15 @@ export type BaseLegendProps = Omit< LegendOrdinalProps, 'shapeStyle' > & { * Function for rendering a custom legend layout. */ render?: ( items: BaseLegendItem[] ) => ReactNode; + /** + * Enable interactive legend items that can toggle series visibility. + * Requires GlobalChartsProvider and chartId to be set. + */ + interactive?: boolean; + /** + * Chart ID for series visibility tracking when interactive mode is enabled. + */ + chartId?: string; }; export type LegendProps = Omit< BaseLegendProps, 'items' > & { 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 999fa3547d1c8..5fc2a5a33d11f 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 @@ -235,6 +235,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >( renderTooltip = renderDefaultTooltip, withStartGlyphs = false, withEndGlyphs = false, + interactive = false, options = {}, onPointerDown = undefined, onPointerUp = undefined, @@ -265,7 +266,17 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >( ); const dataSorted = useChartDataTransform( data ); - const { getElementStyles } = useGlobalChartsContext(); + const { getElementStyles, isSeriesVisible } = useGlobalChartsContext(); + + // Filter out hidden series when using interactive legends, preserving original index + const visibleData = useMemo( () => { + if ( ! chartId || ! interactive ) { + return dataSorted.map( ( series, index ) => ( { series, originalIndex: index } ) ); + } + return dataSorted + .map( ( series, index ) => ( { series, originalIndex: index } ) ) + .filter( ( { series } ) => isSeriesVisible( chartId, series.label ) ); + }, [ dataSorted, chartId, isSeriesVisible, interactive ] ); // Use the keyboard navigation hook const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( { @@ -438,10 +449,10 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >( - { dataSorted.map( ( seriesData, index ) => { + { visibleData.map( ( { series: seriesData, originalIndex } ) => { const { color, lineStyles, glyph } = getElementStyles( { data: seriesData, - index, + index: originalIndex, } ); const lineProps = { @@ -450,10 +461,10 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >( }; return ( - + { withGradientFill && ( ( offset={ stop.offset } stopColor={ stop.color || color } stopOpacity={ stop.opacity ?? 1 } - data-testid={ `line-gradient-stop-${ chartId }-${ index }-${ stopIndex }` } + data-testid={ `line-gradient-stop-${ chartId }-${ originalIndex }-${ stopIndex }` } /> ) ) } @@ -479,7 +490,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >( { ...accessors } fill={ withGradientFill - ? `url(#area-gradient-${ chartId }-${ index + 1 })` + ? `url(#area-gradient-${ chartId }-${ originalIndex + 1 })` : 'transparent' } renderLine={ true } @@ -489,7 +500,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >( { withStartGlyphs && ( ( { withEndGlyphs && ( ( className={ styles[ 'line-chart-legend' ] } shape={ legendShape } chartId={ chartId } + interactive={ interactive } ref={ legendRef } /> ) } diff --git a/projects/js-packages/charts/src/components/line-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/components/line-chart/stories/index.docs.mdx index 9858403073d68..dea4b78edc0eb 100644 --- a/projects/js-packages/charts/src/components/line-chart/stories/index.docs.mdx +++ b/projects/js-packages/charts/src/components/line-chart/stories/index.docs.mdx @@ -95,6 +95,7 @@ The simplest line chart requires only a `data` prop with time-series data: - **`legendPosition`**: Legend position where the legend appears (`'bottom'` by default) - **`legendShape`**: Shape used in legend markers (`'line'` by default) - **`withLegendGlyph`**: Use custom glyphs in legend (`false` by default) +- **`interactive`**: Enable interactive legend items that can toggle series visibility (`false` by default, requires `chartId` and `GlobalChartsProvider`) **Advanced:** - **`options`**: Advanced axis and scale configuration @@ -454,6 +455,38 @@ Use different shapes and custom glyphs in legends: />` } /> +### Interactive Legends + +Enable interactive legends that allow users to toggle series visibility by clicking or using keyboard navigation. This feature requires wrapping your chart in `GlobalChartsProvider` and providing a unique `chartId`: + + + + ` } +/> + +**Interactive Legend Features:** +- **Click or Tap**: Toggle series visibility by clicking on legend items +- **Keyboard Navigation**: Use Tab to focus legend items, then Enter or Space to toggle +- **Accessibility**: Full ARIA support with screen reader announcements +- **Visual Feedback**: Hidden series appear with reduced opacity and strikethrough text +- **Color Stability**: Series colors remain consistent when toggling visibility + +**Requirements:** +- Must be wrapped in `GlobalChartsProvider` +- Must provide a unique `chartId` prop +- Set `interactive={true}` and `showLegend={true}` + +**Note:** Interactive legends are currently supported for LineChart only. Support for other chart types will be added in future releases. + ## Advanced Customization ### Axis Configuration @@ -590,9 +623,9 @@ The chart gracefully handles various error states and edge cases: ### Keyboard Navigation -- **Tab**: Focus the chart +- **Tab**: Focus the chart or legend items (if interactive legends are enabled) - **Arrow Keys**: Navigate between data points -- **Enter/Space**: Activate tooltips or interactive elements +- **Enter/Space**: Activate tooltips or toggle legend items (if interactive legends are enabled) - **Escape**: Close active tooltips ### Screen Reader Support @@ -600,6 +633,7 @@ The chart gracefully handles various error states and edge cases: - Chart container has `role="grid"` with descriptive `aria-label` - Data points are navigable and announced with values - Interactive elements have appropriate ARIA attributes +- Interactive legend items announce their current visibility state ("visible" or "hidden") - Color information is supplemented with patterns and labels ### Focus Management diff --git a/projects/js-packages/charts/src/components/line-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/components/line-chart/stories/index.stories.tsx index a7cf2f812a379..92c08c26dba38 100644 --- a/projects/js-packages/charts/src/components/line-chart/stories/index.stories.tsx +++ b/projects/js-packages/charts/src/components/line-chart/stories/index.stories.tsx @@ -41,10 +41,21 @@ ManySeries.args = { showLegend: true, }; -export const WithLegend: StoryObj< typeof LineChart > = Template.bind( {} ); -WithLegend.args = { +export const WithInteractiveLegend: StoryObj< typeof LineChart > = Template.bind( {} ); +WithInteractiveLegend.args = { ...lineChartStoryArgs, + chartId: 'interactive-legend-demo', showLegend: true, + interactive: true, +}; + +WithInteractiveLegend.parameters = { + docs: { + description: { + story: + 'Line chart with interactive legend. Click or tap legend items to toggle series visibility. Use Tab to focus legend items, then Enter or Space to toggle. Series colors remain stable when toggling visibility.', + }, + }, }; export const CustomLegendPositioning: StoryObj< typeof LineChart > = Template.bind( {} ); diff --git a/projects/js-packages/charts/src/components/line-chart/test/line-chart.test.tsx b/projects/js-packages/charts/src/components/line-chart/test/line-chart.test.tsx index c69c8e85ea624..49f8b292f3591 100644 --- a/projects/js-packages/charts/src/components/line-chart/test/line-chart.test.tsx +++ b/projects/js-packages/charts/src/components/line-chart/test/line-chart.test.tsx @@ -1103,4 +1103,68 @@ describe( 'LineChart', () => { expect( customTooltipRenderer ).toHaveBeenCalled(); } ); } ); + + describe( 'Interactive Legend', () => { + it( 'filters series when interactive legend is enabled and series is toggled', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + // Click on first legend item to hide it + const legendItems = screen.getAllByRole( 'button' ); + await user.click( legendItems[ 0 ] ); + + // The series should now be hidden (aria-pressed = false) + const legendItem = screen.getAllByRole( 'button' )[ 0 ]; + expect( legendItem ).toHaveAttribute( 'aria-pressed', 'false' ); + } ); + + it( 'does not filter series when interactive is false', () => { + render( + + + + ); + + // Legend items should not be interactive + const buttons = screen.queryAllByRole( 'button' ); + expect( buttons ).toHaveLength( 0 ); + } ); + + it( 'shows all series when chartId is missing even if interactive is true', () => { + render( + + + + ); + + // All legend items should be visible (not hidden) + const legendItems = screen.getAllByRole( 'button' ); + legendItems.forEach( item => { + expect( item ).toHaveAttribute( 'aria-pressed', 'true' ); + } ); + } ); + } ); } ); diff --git a/projects/js-packages/charts/src/components/line-chart/types.ts b/projects/js-packages/charts/src/components/line-chart/types.ts index 3fea1d1edf72c..7ffcf9847b768 100644 --- a/projects/js-packages/charts/src/components/line-chart/types.ts +++ b/projects/js-packages/charts/src/components/line-chart/types.ts @@ -41,6 +41,7 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] > { showVertical?: boolean; showHorizontal?: boolean; }; + interactive?: boolean; children?: ReactNode; } diff --git a/projects/js-packages/charts/src/providers/chart-context/global-charts-provider.tsx b/projects/js-packages/charts/src/providers/chart-context/global-charts-provider.tsx index fd680382f0385..e81bc60018575 100644 --- a/projects/js-packages/charts/src/providers/chart-context/global-charts-provider.tsx +++ b/projects/js-packages/charts/src/providers/chart-context/global-charts-provider.tsx @@ -15,6 +15,10 @@ export interface GlobalChartsProviderProps { export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { children, theme } ) => { const [ charts, setCharts ] = useState< Map< string, ChartRegistration > >( () => new Map() ); + // Track hidden series per chart: chartId -> Set + const [ hiddenSeries, setHiddenSeries ] = useState< Map< string, Set< string > > >( + () => new Map() + ); const providerTheme: CompleteChartTheme = useMemo( () => { return theme ? mergeThemes( defaultTheme, theme ) : defaultTheme; @@ -139,6 +143,45 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre [ providerTheme, resolveColor ] ); + // Series visibility management methods + const toggleSeriesVisibility = useCallback( ( chartId: string, seriesLabel: string ) => { + setHiddenSeries( prev => { + const newMap = new Map( prev ); + const chartHidden = newMap.get( chartId ) || new Set(); + const newSet = new Set( chartHidden ); + + if ( newSet.has( seriesLabel ) ) { + newSet.delete( seriesLabel ); + } else { + newSet.add( seriesLabel ); + } + + if ( newSet.size === 0 ) { + newMap.delete( chartId ); + } else { + newMap.set( chartId, newSet ); + } + + return newMap; + } ); + }, [] ); + + const isSeriesVisible = useCallback( + ( chartId: string, seriesLabel: string ) => { + const chartHidden = hiddenSeries.get( chartId ); + return ! chartHidden || ! chartHidden.has( seriesLabel ); + }, + [ hiddenSeries ] + ); + + const getHiddenSeries = useCallback( + ( chartId: string ): Set< string > => { + const set = hiddenSeries.get( chartId ); + return set ? new Set( set ) : new Set< string >(); + }, + [ hiddenSeries ] + ); + const value: GlobalChartsContextValue = useMemo( () => ( { charts, @@ -147,8 +190,21 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre getChartData, theme: providerTheme, getElementStyles, + toggleSeriesVisibility, + isSeriesVisible, + getHiddenSeries, } ), - [ charts, registerChart, unregisterChart, getChartData, providerTheme, getElementStyles ] + [ + charts, + registerChart, + unregisterChart, + getChartData, + providerTheme, + getElementStyles, + toggleSeriesVisibility, + isSeriesVisible, + getHiddenSeries, + ] ); return { children }; diff --git a/projects/js-packages/charts/src/providers/chart-context/test/chart-context.test.tsx b/projects/js-packages/charts/src/providers/chart-context/test/chart-context.test.tsx index 39725cd43d68c..4e7baab5e6629 100644 --- a/projects/js-packages/charts/src/providers/chart-context/test/chart-context.test.tsx +++ b/projects/js-packages/charts/src/providers/chart-context/test/chart-context.test.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react'; +import { render, act } from '@testing-library/react'; import { useMemo } from 'react'; import { GlobalChartsProvider } from '../global-charts-provider'; import { useChartId } from '../hooks/use-chart-id'; @@ -1436,4 +1436,272 @@ describe( 'ChartContext', () => { expect( styles.color ).toBe( '#series-stroke' ); } ); } ); + + describe( 'Series visibility management', () => { + it( 'toggleSeriesVisibility hides a visible series', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + const { rerender } = render( + + + + ); + + // Initially, series should be visible + expect( contextValue.isSeriesVisible( 'test-chart', 'Series 1' ) ).toBe( true ); + + // Toggle to hide + act( () => { + contextValue.toggleSeriesVisibility( 'test-chart', 'Series 1' ); + } ); + + // Rerender to apply state change + rerender( + + + + ); + + // Now should be hidden + expect( contextValue.isSeriesVisible( 'test-chart', 'Series 1' ) ).toBe( false ); + } ); + + it( 'toggleSeriesVisibility shows a hidden series', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + const { rerender } = render( + + + + ); + + // Hide series first + act( () => { + contextValue.toggleSeriesVisibility( 'test-chart', 'Series 1' ); + } ); + rerender( + + + + ); + expect( contextValue.isSeriesVisible( 'test-chart', 'Series 1' ) ).toBe( false ); + + // Toggle to show + act( () => { + contextValue.toggleSeriesVisibility( 'test-chart', 'Series 1' ); + } ); + rerender( + + + + ); + + // Now should be visible again + expect( contextValue.isSeriesVisible( 'test-chart', 'Series 1' ) ).toBe( true ); + } ); + + it( 'manages hidden series independently per chart', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + const { rerender } = render( + + + + ); + + // Hide series in chart1 + act( () => { + contextValue.toggleSeriesVisibility( 'chart1', 'Series A' ); + } ); + rerender( + + + + ); + + // Series A should be hidden in chart1 but visible in chart2 + expect( contextValue.isSeriesVisible( 'chart1', 'Series A' ) ).toBe( false ); + expect( contextValue.isSeriesVisible( 'chart2', 'Series A' ) ).toBe( true ); + } ); + + it( 'manages multiple hidden series in same chart', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + const { rerender } = render( + + + + ); + + // Hide multiple series + act( () => { + contextValue.toggleSeriesVisibility( 'test-chart', 'Series 1' ); + contextValue.toggleSeriesVisibility( 'test-chart', 'Series 2' ); + } ); + rerender( + + + + ); + + // Both should be hidden + expect( contextValue.isSeriesVisible( 'test-chart', 'Series 1' ) ).toBe( false ); + expect( contextValue.isSeriesVisible( 'test-chart', 'Series 2' ) ).toBe( false ); + + // Series 3 should still be visible + expect( contextValue.isSeriesVisible( 'test-chart', 'Series 3' ) ).toBe( true ); + } ); + + it( 'getHiddenSeries returns set of hidden series labels', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + const { rerender } = render( + + + + ); + + // Hide some series + act( () => { + contextValue.toggleSeriesVisibility( 'test-chart', 'Series 1' ); + contextValue.toggleSeriesVisibility( 'test-chart', 'Series 3' ); + } ); + rerender( + + + + ); + + const hidden = contextValue.getHiddenSeries( 'test-chart' ); + + expect( hidden ).toBeInstanceOf( Set ); + expect( hidden.size ).toBe( 2 ); + expect( hidden.has( 'Series 1' ) ).toBe( true ); + expect( hidden.has( 'Series 3' ) ).toBe( true ); + expect( hidden.has( 'Series 2' ) ).toBe( false ); + } ); + + it( 'getHiddenSeries returns empty set for chart with no hidden series', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + render( + + + + ); + + const hidden = contextValue.getHiddenSeries( 'test-chart' ); + + expect( hidden ).toBeInstanceOf( Set ); + expect( hidden.size ).toBe( 0 ); + } ); + + it( 'getHiddenSeries returns defensive copy of set', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + const { rerender } = render( + + + + ); + + // Hide a series + act( () => { + contextValue.toggleSeriesVisibility( 'test-chart', 'Series 1' ); + } ); + rerender( + + + + ); + + const hidden1 = contextValue.getHiddenSeries( 'test-chart' ); + const hidden2 = contextValue.getHiddenSeries( 'test-chart' ); + + // Should be different Set instances (defensive copy) + expect( hidden1 ).not.toBe( hidden2 ); + + // But with same contents + expect( hidden1.size ).toBe( hidden2.size ); + expect( hidden1.has( 'Series 1' ) ).toBe( hidden2.has( 'Series 1' ) ); + + // Modifying returned set should not affect internal state + hidden1.add( 'Series 2' ); + expect( contextValue.isSeriesVisible( 'test-chart', 'Series 2' ) ).toBe( true ); + } ); + + it( 'removes chart entry when all series become visible again', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + const { rerender } = render( + + + + ); + + // Hide and then show a series + act( () => { + contextValue.toggleSeriesVisibility( 'test-chart', 'Series 1' ); + } ); + rerender( + + + + ); + + act( () => { + contextValue.toggleSeriesVisibility( 'test-chart', 'Series 1' ); + } ); + rerender( + + + + ); + + const hidden = contextValue.getHiddenSeries( 'test-chart' ); + + // Should have empty set after all series are visible + expect( hidden.size ).toBe( 0 ); + } ); + } ); } ); diff --git a/projects/js-packages/charts/src/providers/chart-context/types.ts b/projects/js-packages/charts/src/providers/chart-context/types.ts index 0daeb1449d7a8..fd24e0b83465c 100644 --- a/projects/js-packages/charts/src/providers/chart-context/types.ts +++ b/projects/js-packages/charts/src/providers/chart-context/types.ts @@ -31,4 +31,8 @@ export interface GlobalChartsContextValue { getChartData: ( id: string ) => ChartRegistration | undefined; theme: CompleteChartTheme; getElementStyles: ( params: GetElementStylesParams ) => ElementStyles; + // Series visibility management for interactive legends + toggleSeriesVisibility: ( chartId: string, seriesLabel: string ) => void; + isSeriesVisible: ( chartId: string, seriesLabel: string ) => boolean; + getHiddenSeries: ( chartId: string ) => Set< string >; } diff --git a/projects/js-packages/charts/src/stories/legend-config.tsx b/projects/js-packages/charts/src/stories/legend-config.tsx index 36531f00fdb42..9108d4c3d6af3 100644 --- a/projects/js-packages/charts/src/stories/legend-config.tsx +++ b/projects/js-packages/charts/src/stories/legend-config.tsx @@ -58,4 +58,10 @@ export const legendArgTypes = { description: 'Additional CSS class name for legend items. This allows consumers to customize individual legend item styling.', }, + interactive: { + control: { type: 'boolean' as const }, + table: { category: 'Legend' }, + description: + 'Enable interactive legend items that can toggle series visibility. Requires GlobalChartsProvider and chartId to be set.', + }, };