Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
f5a9293
Charts: Add ChartLayout component for shared chart+legend layout
adamwoodnz Mar 11, 2026
e621a42
Charts: Export ChartLayoutProps type from barrel
adamwoodnz Mar 11, 2026
38db8be
Charts: Migrate LineChart to use ChartLayout
adamwoodnz Mar 12, 2026
8b757a7
Charts: Migrate BarChart to use ChartLayout
adamwoodnz Mar 12, 2026
6125f8b
Charts: Migrate PieChart to use ChartLayout
adamwoodnz Mar 12, 2026
db08da8
Charts: Migrate PieSemiCircleChart to use ChartLayout
adamwoodnz Mar 12, 2026
8bda08e
Charts: Migrate LeaderboardChart to use ChartLayout
adamwoodnz Mar 12, 2026
51d2eab
Charts: Restore responsive/loading classes in LeaderboardChart empty …
adamwoodnz Mar 12, 2026
504ea45
Charts: PieChart uses ChartLayout render prop for measurement
adamwoodnz Mar 12, 2026
6c4915e
Charts: Add render prop and measurement support to ChartLayout
adamwoodnz Mar 12, 2026
85f1a86
Charts: Fix PieChart SingleChartContext.Provider placement
adamwoodnz Mar 12, 2026
6b97bbb
Charts: Migrate LineChart, BarChart, PieSemiCircleChart to ChartLayou…
adamwoodnz Mar 12, 2026
216809c
Add changelog entries.
Mar 12, 2026
2956ebb
test(charts): Simplify ChartLayout DOM order assertions
adamwoodnz Mar 12, 2026
ee9b6f3
Charts: Extract shared CSS into ChartLayout, use Stack for pie centering
adamwoodnz Mar 12, 2026
9496b45
refactor(charts): Remove redundant flex and inline styles from pie ch…
adamwoodnz Mar 12, 2026
4710952
chore(charts): Remove superpowers plan from tracked files
adamwoodnz Mar 12, 2026
70d5c01
refactor(charts): Move ChartLayout to charts/private
adamwoodnz Mar 12, 2026
883efdf
fix(charts): Pass measured chart height to SingleChartContext in Line…
adamwoodnz Mar 12, 2026
4b3f3f3
fix(charts): Add full dimensions to pie chart centering Stack
adamwoodnz Mar 12, 2026
a1ab6d0
docs(charts): Fix misleading isMeasured docstring in ChartLayout
adamwoodnz Mar 12, 2026
d670ffc
test(charts): Add ChartLayout render-prop and waitForMeasurement tests
adamwoodnz Mar 12, 2026
30e07f9
refactor(charts): Remove legacy isWaitingForMeasurement prop from Cha…
adamwoodnz Mar 12, 2026
1003cc6
fix(charts): Use useEffect callback instead of setState during render
adamwoodnz Mar 12, 2026
f2a538b
docs(charts): Document waitForMeasurement render-prop requirement
adamwoodnz Mar 12, 2026
fb0cb6e
refactor(charts): Remove unused chartWidth/chartHeight from pie and l…
adamwoodnz Mar 12, 2026
11ce567
fix(charts): Pass measured chart height to SingleChartContext in BarC…
adamwoodnz Mar 12, 2026
e9cc9a3
refactor(charts): Auto-hide ChartLayout during measurement for render…
adamwoodnz Mar 12, 2026
e242b7d
refactor(charts): Simplify measurement gating, mock element size in t…
adamwoodnz Mar 12, 2026
92201ce
refactor(charts): Remove redundant visx tooltip mock
adamwoodnz Mar 12, 2026
d1e3fe9
style(charts): Move pie chart centering styles to CSS modules
adamwoodnz Mar 12, 2026
79a6a3f
fix(charts): Fix 6 test failures from element size mock
adamwoodnz Mar 12, 2026
65fea82
fix(charts): Guard onContentHeightChange for render-prop mode
adamwoodnz Mar 12, 2026
595bbae
test(charts): Wrap mock override in try/finally
adamwoodnz Mar 13, 2026
3539809
Only call onContentHeightChange if measured
adamwoodnz Mar 13, 2026
5cb7e25
fix(charts): Enhance early return condition in LineChartAnnotationsOv…
adamwoodnz Mar 16, 2026
745349a
fix(charts): Pass args through in composition legend stories
adamwoodnz Mar 16, 2026
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
4 changes: 4 additions & 0 deletions projects/js-packages/charts/changelog/pr-47554
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

ChartLayout: Add component for shared chart and legend layout.
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
.bar-chart {

&__svg-wrapper {
flex: 1;
min-height: 0; // Required for flex shrinking
}

svg {
overflow: visible;
}
Expand Down
267 changes: 132 additions & 135 deletions projects/js-packages/charts/src/charts/bar-chart/bar-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { formatNumber } from '@automattic/number-formatters';
import { PatternLines, PatternCircles, PatternWaves, PatternHexagons } from '@visx/pattern';
import { Axis, BarSeries, BarGroup, Grid, XYChart } from '@visx/xychart';
import { __ } from '@wordpress/i18n';
import { Stack } from '@wordpress/ui';
import clsx from 'clsx';
import { useCallback, useContext, useState, useRef, useMemo } from 'react';
import { Legend, useChartLegendItems } from '../../components/legend';
Expand All @@ -12,7 +11,6 @@ import {
useChartDataTransform,
useZeroValueDisplay,
useChartMargin,
useElementSize,
usePrefersReducedMotion,
} from '../../hooks';
import {
Expand All @@ -24,7 +22,8 @@ import {
GlobalChartsContext,
} from '../../providers';
import { attachSubComponents } from '../../utils';
import { useChartChildren, renderLegendSlot } from '../private/chart-composition';
import { useChartChildren } from '../private/chart-composition';
import { ChartLayout } from '../private/chart-layout';
import { SingleChartContext } from '../private/single-chart-context';
import { withResponsive } from '../private/with-responsive';
import styles from './bar-chart.module.scss';
Expand Down Expand Up @@ -113,19 +112,19 @@ const BarChartInternal: FC< BarChartProps > = ( {
const legendItems = useChartLegendItems( dataSorted );
const chartOptions = useBarChartOptions( dataWithVisibleZeros, horizontal, options );
const defaultMargin = useChartMargin( height, chartOptions, dataSorted, theme, horizontal );
const [ svgWrapperRef, , svgWrapperHeight ] = useElementSize< HTMLDivElement >();
const chartRef = useRef< HTMLDivElement >( null );

// Process children for composition API (Legend, etc.)
const { legendChildren, nonLegendChildren } = useChartChildren( children, 'BarChart' );
const hasLegendChild = legendChildren.length > 0;

// Use the measured SVG wrapper height, falling back to the passed height if provided.
// When there's a legend (via prop or composition), we must wait for measurement because
// the legend takes space and the svg-wrapper height will be less than the total height.
const chartHeight = svgWrapperHeight > 0 ? svgWrapperHeight : height;
const hasLegend = showLegend || hasLegendChild;
const isWaitingForMeasurement = hasLegend ? svgWrapperHeight === 0 : ! chartHeight;
const [ measuredChartHeight, setMeasuredChartHeight ] = useState< number | undefined >();

const handleContentHeightChange = useCallback(
( contentHeight: number ) => {
const chartHeight = contentHeight > 0 ? contentHeight : height;
setMeasuredChartHeight( chartHeight );
},
[ height ]
);
const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >( undefined );
const [ isNavigating, setIsNavigating ] = useState( false );

Expand Down Expand Up @@ -343,11 +342,13 @@ const BarChartInternal: FC< BarChartProps > = ( {
value={ {
chartId,
chartWidth: width,
chartHeight,
chartHeight: measuredChartHeight || 0,
} }
>
<Stack
direction="column"
<ChartLayout
legendPosition={ legendPosition }
legendElement={ legendElement }
legendChildren={ legendChildren }
Comment on lines +350 to +351
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if both passed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both will be rendered. This can be tested in storybook using the showLegend control on one of the Composition Legend stories.

Seems odd but I think it's valid configuration. Maybe a consumer could need two; tall chart, separate items, ...?

Not sure if I see the point in blocking it.

Image

gap={ gap }
className={ clsx(
'bar-chart',
Expand All @@ -358,133 +359,129 @@ const BarChartInternal: FC< BarChartProps > = ( {
},
className
) }
style={ { width, height } }
data-testid="bar-chart"
style={ {
width,
height,
visibility: isWaitingForMeasurement ? 'hidden' : 'visible',
} }
data-chart-id={ `bar-chart-${ chartId }` }
trailingContent={ nonLegendChildren }
onContentHeightChange={ handleContentHeightChange }
>
{ legendPosition === 'top' && legendElement }
{ renderLegendSlot( legendChildren, 'top' ) }

<div
className={ styles[ 'bar-chart__svg-wrapper' ] }
ref={ svgWrapperRef }
role="grid"
aria-label={ __( 'Bar chart', 'jetpack-charts' ) }
tabIndex={ 0 }
onKeyDown={ onChartKeyDown }
onFocus={ onChartFocus }
onBlur={ onChartBlur }
>
{ ! isWaitingForMeasurement && (
<div ref={ chartRef }>
<XYChart
theme={ theme }
width={ width }
height={ chartHeight }
margin={ {
...defaultMargin,
...margin,
} }
xScale={ chartOptions.xScale }
yScale={ chartOptions.yScale }
horizontal={ horizontal }
pointerEventsDataKey="nearest"
>
<Grid
columns={ gridVisibility.includes( 'y' ) }
rows={ gridVisibility.includes( 'x' ) }
numTicks={ 4 }
/>

{ withPatterns && (
<>
<defs data-testid="bar-chart-patterns">
{ dataSorted.map( ( seriesData, index ) =>
renderPattern(
index,
getElementStyles( { data: seriesData, index } ).color
)
) }
</defs>
<style>
{ dataSorted.map( ( seriesData, index ) =>
createPatternBorderStyle(
index,
getElementStyles( { data: seriesData, index } ).color
)
) }
</style>
</>
) }

{ highlightedBarStyle && <style>{ highlightedBarStyle }</style> }

{ allSeriesHidden ? (
<text
x={ width / 2 }
y={ chartHeight / 2 }
textAnchor="middle"
fill={ providerTheme.gridStyles?.stroke || '#ccc' }
fontSize="14"
fontFamily="-apple-system,BlinkMacSystemFont,Roboto,Helvetica Neue,sans-serif"
{ ( { contentHeight } ) => {
const chartHeight = contentHeight > 0 ? contentHeight : height;

return (
<div
role="grid"
aria-label={ __( 'Bar chart', 'jetpack-charts' ) }
tabIndex={ 0 }
onKeyDown={ onChartKeyDown }
onFocus={ onChartFocus }
onBlur={ onChartBlur }
>
{ chartHeight > 0 && (
<div ref={ chartRef }>
<XYChart
theme={ theme }
width={ width }
height={ chartHeight }
margin={ {
...defaultMargin,
...margin,
} }
xScale={ chartOptions.xScale }
yScale={ chartOptions.yScale }
horizontal={ horizontal }
pointerEventsDataKey="nearest"
>
{ __(
'All series are hidden. Click legend items to show data.',
'jetpack-charts'
<Grid
columns={ gridVisibility.includes( 'y' ) }
rows={ gridVisibility.includes( 'x' ) }
numTicks={ 4 }
/>

{ withPatterns && (
<>
<defs data-testid="bar-chart-patterns">
{ dataSorted.map( ( seriesData, index ) =>
renderPattern(
index,
getElementStyles( { data: seriesData, index } ).color
)
) }
</defs>
<style>
{ dataSorted.map( ( seriesData, index ) =>
createPatternBorderStyle(
index,
getElementStyles( { data: seriesData, index } ).color
)
) }
</style>
</>
) }
</text>
) : null }

<BarGroup padding={ chartOptions.barGroup.padding }>
{ seriesWithVisibility.map( ( { series: seriesData, index, isVisible } ) => {
// Skip rendering invisible series
if ( ! isVisible ) {
return null;
}

return (
<BarSeries
key={ seriesData?.label }
dataKey={ seriesData?.label }
data={ seriesData.data as DataPointDate[] }
yAccessor={ chartOptions.accessors.yAccessor }
xAccessor={ chartOptions.accessors.xAccessor }
colorAccessor={ getBarBackground( index ) }

{ highlightedBarStyle && <style>{ highlightedBarStyle }</style> }

{ allSeriesHidden ? (
<text
x={ width / 2 }
y={ chartHeight / 2 }
textAnchor="middle"
fill={ providerTheme.gridStyles?.stroke || '#ccc' }
fontSize="14"
fontFamily="-apple-system,BlinkMacSystemFont,Roboto,Helvetica Neue,sans-serif"
>
{ __(
'All series are hidden. Click legend items to show data.',
'jetpack-charts'
) }
</text>
) : null }

<BarGroup padding={ chartOptions.barGroup.padding }>
{ seriesWithVisibility.map( ( { series: seriesData, index, isVisible } ) => {
// Skip rendering invisible series
if ( ! isVisible ) {
return null;
}

return (
<BarSeries
key={ seriesData?.label }
dataKey={ seriesData?.label }
data={ seriesData.data as DataPointDate[] }
yAccessor={ chartOptions.accessors.yAccessor }
xAccessor={ chartOptions.accessors.xAccessor }
colorAccessor={ getBarBackground( index ) }
/>
);
} ) }
</BarGroup>

<Axis { ...chartOptions.axis.x } />
<Axis { ...chartOptions.axis.y } />

{ withTooltips && (
<AccessibleTooltip
detectBounds
snapTooltipToDatumX
snapTooltipToDatumY
renderTooltip={ renderTooltip || renderDefaultTooltip }
selectedIndex={ selectedIndex }
tooltipRef={ tooltipRef }
keyboardFocusedClassName={
styles[ 'bar-chart__tooltip--keyboard-focused' ]
}
series={ data }
mode="individual"
/>
);
} ) }
</BarGroup>

<Axis { ...chartOptions.axis.x } />
<Axis { ...chartOptions.axis.y } />

{ withTooltips && (
<AccessibleTooltip
detectBounds
snapTooltipToDatumX
snapTooltipToDatumY
renderTooltip={ renderTooltip || renderDefaultTooltip }
selectedIndex={ selectedIndex }
tooltipRef={ tooltipRef }
keyboardFocusedClassName={ styles[ 'bar-chart__tooltip--keyboard-focused' ] }
series={ data }
mode="individual"
/>
) }
</XYChart>
) }
</XYChart>
</div>
) }
</div>
) }
</div>

{ legendPosition === 'bottom' && legendElement }
{ renderLegendSlot( legendChildren, 'bottom' ) }

{ nonLegendChildren }
</Stack>
);
} }
</ChartLayout>
</SingleChartContext.Provider>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,12 +260,7 @@ export const WithCompositionLegend: StoryObj< typeof BarChart > = {
render: args => {
const legend = extractLegendConfig( args );
return (
<BarChart
data={ args.data || [ medalCountsData[ 0 ], medalCountsData[ 1 ], medalCountsData[ 2 ] ] }
withTooltips={ true }
gridVisibility="x"
chartId="composition-bar-chart"
>
<BarChart { ...Default.args } { ...args } chartId="composition-bar-chart">
<BarChart.Legend { ...legend } />
</BarChart>
);
Expand Down
Loading
Loading