Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fad4485
Add series visibility methods to chart context types
annacmc Oct 17, 2025
b7e5ee3
Implement series visibility state management in chart provider
annacmc Oct 17, 2025
0d270a6
Add interactive and chartId props to legend types
annacmc Oct 17, 2025
a857d58
Pass chartId prop through to BaseLegend component
annacmc Oct 17, 2025
9c2aa05
Add CSS styles for interactive legend states
annacmc Oct 17, 2025
4fe0b4b
Add click handlers and accessibility for interactive legends
annacmc Oct 17, 2025
f3e6c28
Filter hidden series in LineChart based on legend visibility
annacmc Oct 17, 2025
c55d5c9
Add interactive argType to legend Storybook configuration
annacmc Oct 17, 2025
4cfde90
Add changelog entry for interactive legend feature
annacmc Oct 17, 2025
294a38b
Fix CSS selector for inactive legend item strikethrough
annacmc Oct 17, 2025
1fb1772
Fix TypeScript KeyboardEvent type import in legend
annacmc Oct 17, 2025
2bddf80
Update legend aria-label to use keyboard-inclusive wording
annacmc Oct 17, 2025
5bf94ed
Add defensive copy in getHiddenSeries to prevent state mutation
annacmc Oct 17, 2025
db5da63
Wire interactive prop through chart components to Legend
annacmc Oct 17, 2025
e49199f
Fix interactive legend filtering and scope to LineChart only
annacmc Oct 19, 2025
3b4cfa4
Add tests for series visibility management
annacmc Oct 19, 2025
9cbfd3a
Add interactive legend test coverage for BaseLegend and LineChart
annacmc Oct 19, 2025
a54623a
Fix TypeScript error by adding required withGradientFill prop to tests
annacmc Oct 20, 2025
988602e
Add documentation for interactive legend feature
annacmc Oct 20, 2025
340a2ec
Add interactive prop to Legend API docs and create interactive legend…
annacmc Oct 20, 2025
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 legend interactivity
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ export const Legend = forwardRef< HTMLDivElement, LegendProps >(
return null;
}

return <BaseLegend ref={ ref } items={ legendItems } { ...props } />;
return <BaseLegend ref={ ref } items={ legendItems } { ...props } chartId={ contextChartId } />;
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 ),
Expand All @@ -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 )
) : (
Expand All @@ -122,77 +179,97 @@ export const BaseLegend: ForwardRefExoticComponent<
...theme.legendContainerStyles,
} }
>
{ labels.map( ( label, i ) => (
<LegendItem
className={ clsx(
'visx-legend-item',
styles[ 'legend-item' ],
legendItemClassName
) }
data-testid="legend-item"
key={ `legend-${ label.text }-${ i }` }
margin={ itemMargin }
flexDirection={
orientation === 'vertical' && alignment === 'end' ? 'row-reverse' : itemDirection
}
{ ...legendItemProps }
>
{ items[ i ]?.renderGlyph ? (
<svg
width={ items[ i ]?.glyphSize * 2 }
height={ items[ i ]?.glyphSize * 2 }
data-testid="legend-glyph"
>
<Group>
{ 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,
} ) }
</Group>
</svg>
) : (
<LegendShape
shape={ shape }
height={ shapeHeight }
width={ shapeWidth }
margin={ shapeMargin }
item={ domain[ i ] }
itemIndex={ i }
label={ label }
fill={ fill }
size={ size }
shapeStyle={ getShapeStyle }
/>
) }
<LegendLabel
className={ clsx( 'visx-legend-label', styles[ 'legend-item-label' ] ) }
style={ {
justifyContent: labelAlign,
flex: labelFlex,
margin: labelMargin,
...theme.legendLabelStyles,
} }
{ ...legendLabelProps }
{ labels.map( ( label, i ) => {
const visible = isSeriesVisible( label.text );
const handleClick = createClickHandler( label.text );
const handleKeyDown = createKeyDownHandler( label.text );

return (
<LegendItem
className={ clsx(
'visx-legend-item',
styles[ 'legend-item' ],
interactive && styles[ 'legend-item--interactive' ],
! visible && styles[ 'legend-item--inactive' ],
legendItemClassName
) }
data-testid="legend-item"
key={ `legend-${ label.text }-${ i }` }
margin={ itemMargin }
flexDirection={
orientation === 'vertical' && alignment === 'end'
? 'row-reverse'
: itemDirection
}
onClick={ handleClick }
onKeyDown={ handleKeyDown }
role={ interactive ? 'button' : undefined }
tabIndex={ interactive ? 0 : undefined }
aria-pressed={ interactive ? visible : undefined }
aria-label={
interactive
? `${ label.text }: ${ visible ? 'visible' : 'hidden' }. Toggle visibility.`
: undefined
}
{ ...legendItemProps }
>
<LegendText
text={ label.text }
textOverflow={ textOverflow }
maxWidth={ maxWidth }
/>
{ items.find( item => item.label === label.text )?.value && (
<span className={ styles[ 'legend-item-value' ] }>
{ '\u00A0' }
{ items.find( item => item.label === label.text )?.value }
</span>
{ items[ i ]?.renderGlyph ? (
<svg
width={ items[ i ]?.glyphSize * 2 }
height={ items[ i ]?.glyphSize * 2 }
data-testid="legend-glyph"
>
<Group>
{ 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,
} ) }
</Group>
</svg>
) : (
<LegendShape
shape={ shape }
height={ shapeHeight }
width={ shapeWidth }
margin={ shapeMargin }
item={ domain[ i ] }
itemIndex={ i }
label={ label }
fill={ fill }
size={ size }
shapeStyle={ getShapeStyle }
/>
) }
</LegendLabel>
</LegendItem>
) ) }
<LegendLabel
className={ clsx( 'visx-legend-label', styles[ 'legend-item-label' ] ) }
style={ {
justifyContent: labelAlign,
flex: labelFlex,
margin: labelMargin,
...theme.legendLabelStyles,
} }
{ ...legendLabelProps }
>
<LegendText
text={ label.text }
textOverflow={ textOverflow }
maxWidth={ maxWidth }
/>
{ items.find( item => item.label === label.text )?.value && (
<span className={ styles[ 'legend-item-value' ] }>
{ '\u00A0' }
{ items.find( item => item.label === label.text )?.value }
</span>
) }
</LegendLabel>
</LegendItem>
);
} ) }
</div>
) }
</LegendOrdinal>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

<Source
language="tsx"
code={ `import { GlobalChartsProvider, LineChart, Legend } from '@automattic/charts';

<GlobalChartsProvider>
<LineChart
chartId="sales-chart"
data={ salesData }
showLegend={ true }
interactive={ true }
/>
</GlobalChartsProvider>` }
/>

### Standalone Interactive Legend

Interactive legends also work with standalone Legend components when using `chartId`:

<Source
language="tsx"
code={ `<GlobalChartsProvider>
<LineChart
chartId="sales-chart"
data={ salesData }
showLegend={ false }
interactive={ true }
/>

<Legend
chartId="sales-chart"
interactive={ true }
orientation="horizontal"
/>
</GlobalChartsProvider>` }
/>

### 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
Expand All @@ -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")
Loading
Loading