diff --git a/projects/js-packages/charts/changelog/pr-47554 b/projects/js-packages/charts/changelog/pr-47554 new file mode 100644 index 000000000000..511329c3f103 --- /dev/null +++ b/projects/js-packages/charts/changelog/pr-47554 @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +ChartLayout: Add component for shared chart and legend layout. diff --git a/projects/js-packages/charts/src/charts/bar-chart/bar-chart.module.scss b/projects/js-packages/charts/src/charts/bar-chart/bar-chart.module.scss index 363d1758584a..fd2246be150d 100644 --- a/projects/js-packages/charts/src/charts/bar-chart/bar-chart.module.scss +++ b/projects/js-packages/charts/src/charts/bar-chart/bar-chart.module.scss @@ -1,10 +1,5 @@ .bar-chart { - &__svg-wrapper { - flex: 1; - min-height: 0; // Required for flex shrinking - } - svg { overflow: visible; } diff --git a/projects/js-packages/charts/src/charts/bar-chart/bar-chart.tsx b/projects/js-packages/charts/src/charts/bar-chart/bar-chart.tsx index 478843f3413d..e8e46abae860 100644 --- a/projects/js-packages/charts/src/charts/bar-chart/bar-chart.tsx +++ b/projects/js-packages/charts/src/charts/bar-chart/bar-chart.tsx @@ -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'; @@ -12,7 +11,6 @@ import { useChartDataTransform, useZeroValueDisplay, useChartMargin, - useElementSize, usePrefersReducedMotion, } from '../../hooks'; import { @@ -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'; @@ -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 ); @@ -343,11 +342,13 @@ const BarChartInternal: FC< BarChartProps > = ( { value={ { chartId, chartWidth: width, - chartHeight, + chartHeight: measuredChartHeight || 0, } } > - = ( { }, 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' ) } - -
- { ! isWaitingForMeasurement && ( -
- - - - { withPatterns && ( - <> - - { dataSorted.map( ( seriesData, index ) => - renderPattern( - index, - getElementStyles( { data: seriesData, index } ).color - ) - ) } - - - - ) } - - { highlightedBarStyle && } - - { allSeriesHidden ? ( - { + const chartHeight = contentHeight > 0 ? contentHeight : height; + + return ( +
+ { chartHeight > 0 && ( +
+ - { __( - 'All series are hidden. Click legend items to show data.', - 'jetpack-charts' + + + { withPatterns && ( + <> + + { dataSorted.map( ( seriesData, index ) => + renderPattern( + index, + getElementStyles( { data: seriesData, index } ).color + ) + ) } + + + ) } - - ) : null } - - - { seriesWithVisibility.map( ( { series: seriesData, index, isVisible } ) => { - // Skip rendering invisible series - if ( ! isVisible ) { - return null; - } - - return ( - { highlightedBarStyle } } + + { allSeriesHidden ? ( + + { __( + 'All series are hidden. Click legend items to show data.', + 'jetpack-charts' + ) } + + ) : null } + + + { seriesWithVisibility.map( ( { series: seriesData, index, isVisible } ) => { + // Skip rendering invisible series + if ( ! isVisible ) { + return null; + } + + return ( + + ); + } ) } + + + + + + { withTooltips && ( + - ); - } ) } - - - - - - { withTooltips && ( - - ) } - + ) } + +
+ ) }
- ) } -
- - { legendPosition === 'bottom' && legendElement } - { renderLegendSlot( legendChildren, 'bottom' ) } - - { nonLegendChildren } - + ); + } } + ); }; diff --git a/projects/js-packages/charts/src/charts/bar-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/charts/bar-chart/stories/index.stories.tsx index 2e9ac1c6b9eb..a7cd75c1e30c 100644 --- a/projects/js-packages/charts/src/charts/bar-chart/stories/index.stories.tsx +++ b/projects/js-packages/charts/src/charts/bar-chart/stories/index.stories.tsx @@ -260,12 +260,7 @@ export const WithCompositionLegend: StoryObj< typeof BarChart > = { render: args => { const legend = extractLegendConfig( args ); return ( - + ); diff --git a/projects/js-packages/charts/src/charts/leaderboard-chart/leaderboard-chart.tsx b/projects/js-packages/charts/src/charts/leaderboard-chart/leaderboard-chart.tsx index 53442550be89..d17b0b88aaee 100644 --- a/projects/js-packages/charts/src/charts/leaderboard-chart/leaderboard-chart.tsx +++ b/projects/js-packages/charts/src/charts/leaderboard-chart/leaderboard-chart.tsx @@ -16,7 +16,8 @@ import { useGlobalChartsTheme, } from '../../providers'; import { formatMetricValue, 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 { useLeaderboardLegendItems } from './hooks'; @@ -246,37 +247,30 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( { // Handle empty or undefined data if ( ! data || data.length === 0 ) { return ( - - +
{ loading ? __( 'Loading…', 'jetpack-charts' ) : __( 'No data available', 'jetpack-charts' ) }
- - { nonLegendChildren } -
+
); } @@ -297,16 +291,11 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( { ); return ( - - + = ( { width: propWidth || undefined, height: propHeight || undefined, } } + data-testid="leaderboard-chart-container" + trailingContent={ nonLegendChildren } > - { legendPosition === 'top' && legendElement } - { renderLegendSlot( legendChildren, 'top' ) } -
{ allSeriesHidden ? (
@@ -372,12 +360,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( { ) }
- - { legendPosition === 'bottom' && legendElement } - { renderLegendSlot( legendChildren, 'bottom' ) } - - { nonLegendChildren } - + ); }; diff --git a/projects/js-packages/charts/src/charts/line-chart/line-chart.module.scss b/projects/js-packages/charts/src/charts/line-chart/line-chart.module.scss index 0f3fb796c8e0..007d7ad8bba3 100644 --- a/projects/js-packages/charts/src/charts/line-chart/line-chart.module.scss +++ b/projects/js-packages/charts/src/charts/line-chart/line-chart.module.scss @@ -1,11 +1,6 @@ .line-chart { position: relative; - &__svg-wrapper { - flex: 1; - min-height: 0; // Required for flex shrinking - } - &--animated { path { diff --git a/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx b/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx index 8a800e2563a6..1e5c17f5ea3f 100644 --- a/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx @@ -4,17 +4,23 @@ import { LinearGradient } from '@visx/gradient'; import { scaleTime } from '@visx/scale'; import { XYChart, AreaSeries, Grid, Axis, DataContext } from '@visx/xychart'; import { __ } from '@wordpress/i18n'; -import { Stack } from '@wordpress/ui'; import clsx from 'clsx'; import { differenceInHours, differenceInYears } from 'date-fns'; -import { useMemo, useContext, forwardRef, useImperativeHandle, useState, useRef } from 'react'; +import { + useMemo, + useContext, + forwardRef, + useImperativeHandle, + useState, + useRef, + useCallback, +} from 'react'; import { Legend, useChartLegendItems } from '../../components/legend'; import { AccessibleTooltip, useKeyboardNavigation } from '../../components/tooltip'; import { useXYChartTheme, useChartDataTransform, useChartMargin, - useElementSize, usePrefersReducedMotion, } from '../../hooks'; import { @@ -26,7 +32,8 @@ import { useGlobalChartsTheme, } 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 { DefaultGlyph } from '../private/default-glyph'; import { SingleChartContext, type SingleChartRef } from '../private/single-chart-context'; import { withResponsive } from '../private/with-responsive'; @@ -285,7 +292,6 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >( const providerTheme = useGlobalChartsTheme(); const theme = useXYChartTheme( data ); const chartId = useChartId( providedChartId ); - const [ svgWrapperRef, , svgWrapperHeight ] = useElementSize< HTMLDivElement >(); const chartRef = useRef< HTMLDivElement >( null ); const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >( undefined ); const [ isNavigating, setIsNavigating ] = useState( false ); @@ -293,14 +299,17 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >( // Process children for composition API (Legend, etc.) const { legendChildren, nonLegendChildren } = useChartChildren( children, 'LineChart' ); - 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 >(); + + // Callback for ChartLayout to notify us when the measured content height changes. + // We compute chartHeight the same way the render prop does so the context stays in sync. + const handleContentHeightChange = useCallback( + ( contentHeight: number ) => { + const chartHeight = contentHeight > 0 ? contentHeight : height; + setMeasuredChartHeight( chartHeight ); + }, + [ height ] + ); // Forward the external ref to the internal ref useImperativeHandle( @@ -472,11 +481,13 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >( chartId, chartRef: internalChartRef, chartWidth: width, - chartHeight, + chartHeight: measuredChartHeight || 0, } } > - ( { [ styles[ 'line-chart--animated' ] ]: animation && ! prefersReducedMotion }, className ) } + style={ { width, height } } data-testid="line-chart" - style={ { - width, - height, - visibility: isWaitingForMeasurement ? 'hidden' : 'visible', - } } + trailingContent={ nonLegendChildren } + onContentHeightChange={ handleContentHeightChange } > - { legendPosition === 'top' && legendElement } - { renderLegendSlot( legendChildren, 'top' ) } - -
- { ! isWaitingForMeasurement && ( -
- - { gridVisibility !== 'none' && } - { chartOptions.axis.x.display && } - { chartOptions.axis.y.display && } - - { allSeriesHidden ? ( - { + // Use the measured height, falling back to the passed height if provided. + const chartHeight = contentHeight > 0 ? contentHeight : height; + + return ( +
+ { chartHeight > 0 && ( +
+ - { __( - 'All series are hidden. Click legend items to show data.', - 'jetpack-charts' - ) } - - ) : null } - - { seriesWithVisibility.map( ( { series: seriesData, index, isVisible } ) => { - // Skip rendering invisible series - if ( ! isVisible ) { - return null; - } - - const { color, lineStyles, glyph } = getElementStyles( { - data: seriesData, - index, - } ); - - const lineProps = { - stroke: color, - ...lineStyles, - }; - - return ( - - { withGradientFill && ( - - { seriesData.options?.gradient?.stops?.map( ( stop, stopIndex ) => ( - } + { chartOptions.axis.x.display && } + { chartOptions.axis.y.display && } + + { allSeriesHidden ? ( + + { __( + 'All series are hidden. Click legend items to show data.', + 'jetpack-charts' + ) } + + ) : null } + + { seriesWithVisibility.map( ( { series: seriesData, index, isVisible } ) => { + // Skip rendering invisible series + if ( ! isVisible ) { + return null; + } + + const { color, lineStyles, glyph } = getElementStyles( { + data: seriesData, + index, + } ); + + const lineProps = { + stroke: color, + ...lineStyles, + }; + + return ( + + { withGradientFill && ( + + { seriesData.options?.gradient?.stops?.map( ( stop, stopIndex ) => ( + + ) ) } + + ) } + + + { withStartGlyphs && ( + - ) ) } - - ) } - + ) } + + ); + } ) } + + { withTooltips && ( + + ) } - { withStartGlyphs && ( - - ) } - - { withEndGlyphs && ( - - ) } - - ); - } ) } - - { withTooltips && ( - - ) } - - { /* Component to expose scale data via ref */ } - - + { /* Component to expose scale data via ref */ } + + +
+ ) }
- ) } -
- - { legendPosition === 'bottom' && legendElement } - { renderLegendSlot( legendChildren, 'bottom' ) } - - { nonLegendChildren } - + ); + } } + ); } diff --git a/projects/js-packages/charts/src/charts/line-chart/private/line-chart-annotations-overlay.tsx b/projects/js-packages/charts/src/charts/line-chart/private/line-chart-annotations-overlay.tsx index fad76b40f511..d19d5eb1ff56 100644 --- a/projects/js-packages/charts/src/charts/line-chart/private/line-chart-annotations-overlay.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/private/line-chart-annotations-overlay.tsx @@ -99,8 +99,7 @@ const LineChartAnnotationsOverlay: FC< LineChartAnnotationsProps > = ( { childre }; }, [ getScalesData, chartWidth, chartHeight ] ); - // Early return if no chart data available - if ( ! chartRef || ! children ) { + if ( ! chartRef || ! children || ! chartWidth || ! chartHeight ) { return null; } diff --git a/projects/js-packages/charts/src/charts/pie-chart/pie-chart.module.scss b/projects/js-packages/charts/src/charts/pie-chart/pie-chart.module.scss index ff3482db7ff4..3c6be251d449 100644 --- a/projects/js-packages/charts/src/charts/pie-chart/pie-chart.module.scss +++ b/projects/js-packages/charts/src/charts/pie-chart/pie-chart.module.scss @@ -1,8 +1,5 @@ .pie-chart { - display: flex; - flex-direction: column; overflow: hidden; - align-items: center; // Fill parent when no explicit width/height provided &--responsive { @@ -10,13 +7,8 @@ width: 100%; } - &__svg-wrapper { - flex: 1; - min-height: 0; // Required for flex shrinking - min-width: 0; // Required for flex shrinking + &__centering { width: 100%; - display: flex; - align-items: center; - justify-content: center; + height: 100%; } } diff --git a/projects/js-packages/charts/src/charts/pie-chart/pie-chart.tsx b/projects/js-packages/charts/src/charts/pie-chart/pie-chart.tsx index a81426bc596f..6f808e139230 100644 --- a/projects/js-packages/charts/src/charts/pie-chart/pie-chart.tsx +++ b/projects/js-packages/charts/src/charts/pie-chart/pie-chart.tsx @@ -7,7 +7,7 @@ import clsx from 'clsx'; import { useCallback, useContext, useMemo } from 'react'; import { Legend, useChartLegendItems } from '../../components/legend'; import { BaseTooltip } from '../../components/tooltip'; -import { useElementSize, useInteractiveLegendData, usePrefersReducedMotion } from '../../hooks'; +import { useInteractiveLegendData, usePrefersReducedMotion } from '../../hooks'; import { GlobalChartsProvider, useChartId, @@ -18,12 +18,8 @@ import { } from '../../providers'; import { attachSubComponents } from '../../utils'; import { getStringWidth } from '../../visx/text'; -import { - ChartSVG, - ChartHTML, - useChartChildren, - renderLegendSlot, -} from '../private/chart-composition'; +import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition'; +import { ChartLayout } from '../private/chart-layout'; import { RadialWipeAnimation } from '../private/radial-wipe-animation/'; import { SingleChartContext } from '../private/single-chart-context'; import { withResponsive, ResponsiveConfig } from '../private/with-responsive'; @@ -189,7 +185,6 @@ const PieChartInternal = ( { const providerTheme = useGlobalChartsTheme(); const chartId = useChartId( providedChartId ); - const [ svgWrapperRef, svgWrapperWidth, svgWrapperHeight ] = useElementSize< HTMLDivElement >(); const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, hideTooltip, showTooltip } = useTooltip< DataPointPercentage >(); @@ -264,34 +259,9 @@ const PieChartInternal = ( { ); } - // Calculate the actual pie size: - // - Measure available space from the svg-wrapper - // - If size prop provided: use it as max, but shrink if container is smaller - // - If no size prop: fill available space - const availableWidth = svgWrapperWidth > 0 ? svgWrapperWidth : 300; - const availableHeight = svgWrapperHeight > 0 ? svgWrapperHeight : 300; - const availableSize = Math.min( availableWidth, availableHeight ); - const actualSize = size ? Math.min( size, availableSize ) : availableSize; - - const width = actualSize; - const height = actualSize; - - // Calculate radius based on width/height - const radius = Math.min( width, height ) / 2; - - // Center the chart in the available space - const centerX = width / 2; - const centerY = height / 2; - // Calculate the angle between each (use original data length for consistent spacing) const padAngle = gapScale * ( ( 2 * Math.PI ) / data.length ); - const outerRadius = radius - padding; - const innerRadius = thickness === 0 ? 0 : outerRadius * ( 1 - thickness ); - - const maxCornerRadius = ( outerRadius - innerRadius ) / 2; - const cornerRadius = cornerScale ? Math.min( cornerScale * outerRadius, maxCornerRadius ) : 0; - // Map the data to include index for color assignment // When interactive, we need to find the original index to maintain consistent colors const dataWithIndex = visibleData.map( d => { @@ -325,16 +295,12 @@ const PieChartInternal = ( { ); return ( - - + + { withTooltips && tooltipOpen && tooltipData && ( + +
{ renderTooltip( { tooltipData } ) }
+
+ ) } + { htmlChildren } + { otherChildren } + + } > - { legendPosition === 'top' && legendElement } - { renderLegendSlot( legendChildren, 'top' ) } - -
- - - - - - - { allSegmentsHidden ? ( - { + const availableWidth = contentWidth > 0 ? contentWidth : 300; + const availableHeight = contentHeight > 0 ? contentHeight : 300; + const availableSize = Math.min( availableWidth, availableHeight ); + const actualSize = size ? Math.min( size, availableSize ) : availableSize; + + const width = actualSize; + const height = actualSize; + + const radius = Math.min( width, height ) / 2; + const centerX = width / 2; + const centerY = height / 2; + + const outerRadius = radius - padding; + const innerRadius = thickness === 0 ? 0 : outerRadius * ( 1 - thickness ); + + const maxCornerRadius = ( outerRadius - innerRadius ) / 2; + const cornerRadius = cornerScale + ? Math.min( cornerScale * outerRadius, maxCornerRadius ) + : 0; + + return ( + + + + + + + - { __( - 'All segments are hidden. Click legend items to show data.', - 'jetpack-charts' - ) } - - ) : ( - - data={ dataWithIndex } - pieValue={ accessors.value } - outerRadius={ outerRadius } - innerRadius={ innerRadius } - padAngle={ padAngle } - cornerRadius={ cornerRadius } - > - { pie => { - return pie.arcs.map( ( arc, index ) => { - const [ centroidX, centroidY ] = pie.path.centroid( arc ); - const hasSpaceForLabel = arc.endAngle - arc.startAngle >= 0.25; - const handleMouseMove = ( event: MouseEvent< SVGElement > ) => { - if ( ! withTooltips ) { - return; - } - - // Don't show tooltip until container bounds are measured - if ( containerBounds.width === 0 || containerBounds.height === 0 ) { - return; - } - - // Use clientX/Y and subtract containerBounds to cancel out any stale offset. - // TooltipInPortal calculates: tooltipLeft + containerBounds.left + scrollX - // By passing (clientX - containerBounds.left), we get: - // (clientX - containerBounds.left) + containerBounds.left + scrollX = clientX + scrollX - // This gives correct page coordinates regardless of stale bounds. - showTooltip( { - tooltipData: arc.data, - tooltipLeft: event.clientX - containerBounds.left + tooltipOffsetX, - tooltipTop: event.clientY - containerBounds.top + tooltipOffsetY, - } ); - }; - - const pathProps: SVGProps< SVGPathElement > & { 'data-testid'?: string } = { - d: pie.path( arc ) || '', - fill: accessors.fill( arc.data ), - 'data-testid': 'pie-segment', - }; - - const groupProps: SVGProps< SVGGElement > = {}; - if ( withTooltips ) { - groupProps.onMouseMove = handleMouseMove; - groupProps.onMouseLeave = onMouseLeave; - } - - // Estimate text width more accurately for background sizing - const fontSize = 12; - const estimatedTextWidth = getStringWidth( arc.data.label, { fontSize } ); - const labelPadding = 6; - const backgroundWidth = estimatedTextWidth + labelPadding * 2; - const backgroundHeight = fontSize + labelPadding * 2; - - return ( - - - { showLabels && hasSpaceForLabel && ( - - { providerTheme.labelBackgroundColor && ( - + { allSegmentsHidden ? ( + + { __( + 'All segments are hidden. Click legend items to show data.', + 'jetpack-charts' + ) } + + ) : ( + + data={ dataWithIndex } + pieValue={ accessors.value } + outerRadius={ outerRadius } + innerRadius={ innerRadius } + padAngle={ padAngle } + cornerRadius={ cornerRadius } + > + { pie => { + return pie.arcs.map( ( arc, index ) => { + const [ centroidX, centroidY ] = pie.path.centroid( arc ); + const hasSpaceForLabel = arc.endAngle - arc.startAngle >= 0.25; + const handleMouseMove = ( event: MouseEvent< SVGElement > ) => { + if ( ! withTooltips ) { + return; + } + + // Don't show tooltip until container bounds are measured + if ( containerBounds.width === 0 || containerBounds.height === 0 ) { + return; + } + + // Use clientX/Y and subtract containerBounds to cancel out any stale offset. + // TooltipInPortal calculates: tooltipLeft + containerBounds.left + scrollX + // By passing (clientX - containerBounds.left), we get: + // (clientX - containerBounds.left) + containerBounds.left + scrollX = clientX + scrollX + // This gives correct page coordinates regardless of stale bounds. + showTooltip( { + tooltipData: arc.data, + tooltipLeft: event.clientX - containerBounds.left + tooltipOffsetX, + tooltipTop: event.clientY - containerBounds.top + tooltipOffsetY, + } ); + }; + + const pathProps: SVGProps< SVGPathElement > & { + 'data-testid'?: string; + } = { + d: pie.path( arc ) || '', + fill: accessors.fill( arc.data ), + 'data-testid': 'pie-segment', + }; + + const groupProps: SVGProps< SVGGElement > = {}; + if ( withTooltips ) { + groupProps.onMouseMove = handleMouseMove; + groupProps.onMouseLeave = onMouseLeave; + } + + // Estimate text width more accurately for background sizing + const fontSize = 12; + const estimatedTextWidth = getStringWidth( arc.data.label, { + fontSize, + } ); + const labelPadding = 6; + const backgroundWidth = estimatedTextWidth + labelPadding * 2; + const backgroundHeight = fontSize + labelPadding * 2; + + return ( + + + { showLabels && hasSpaceForLabel && ( + + { providerTheme.labelBackgroundColor && ( + + ) } + + { arc.data.label } + + ) } - - { arc.data.label } - - ) } - - ); - } ); - } } - - ) } - - { /* Render SVG children (like Group, Text) inside the SVG */ } - { ! allSegmentsHidden && svgChildren } - - -
- - { legendPosition === 'bottom' && legendElement } - { renderLegendSlot( legendChildren, 'bottom' ) } - - { withTooltips && tooltipOpen && tooltipData && ( - -
{ renderTooltip( { tooltipData } ) }
-
- ) } - - { /* Render HTML component children from PieChart.HTML */ } - { htmlChildren } + ); + } ); + } } + + ) } - { /* Render other React children for backward compatibility */ } - { otherChildren } -
+ { /* Render SVG children (like Group, Text) inside the SVG */ } + { ! allSegmentsHidden && svgChildren } + + + + ); + } } +
); }; diff --git a/projects/js-packages/charts/src/charts/pie-chart/stories/donut.stories.tsx b/projects/js-packages/charts/src/charts/pie-chart/stories/donut.stories.tsx index 8b8d62591169..7babb98bcd80 100644 --- a/projects/js-packages/charts/src/charts/pie-chart/stories/donut.stories.tsx +++ b/projects/js-packages/charts/src/charts/pie-chart/stories/donut.stories.tsx @@ -223,13 +223,7 @@ export const WithCompositionLegend: Story = { render: args => { const legend = extractLegendConfig( args ); return ( - + User Stats diff --git a/projects/js-packages/charts/src/charts/pie-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/charts/pie-chart/stories/index.stories.tsx index f39a4112abe7..e7e2b349a8b3 100644 --- a/projects/js-packages/charts/src/charts/pie-chart/stories/index.stories.tsx +++ b/projects/js-packages/charts/src/charts/pie-chart/stories/index.stories.tsx @@ -186,12 +186,7 @@ export const WithCompositionLegend: Story = { render: args => { const legend = extractLegendConfig( args ); return ( - + ); @@ -403,7 +398,6 @@ export const CustomLabelColors: Story = { ], labelTextColor: '#FFFFFF', // White text for contrast against dark background labelBackgroundColor: 'rgba(0, 0, 0, 0.75)', // Dark semi-transparent background - size: 400, }, parameters: { docs: { diff --git a/projects/js-packages/charts/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss b/projects/js-packages/charts/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss index de49033a51ec..c1d2be2f0be6 100644 --- a/projects/js-packages/charts/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +++ b/projects/js-packages/charts/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss @@ -5,15 +5,9 @@ width: 100%; } - // Flex wrapper that fills remaining Stack space and measures the SVG area - &__svg-wrapper { - flex: 1; - min-height: 0; // Required for flex shrinking - min-width: 0; // Required for flex shrinking + &__centering { width: 100%; - display: flex; - align-items: center; - justify-content: center; + height: 100%; } .label { diff --git a/projects/js-packages/charts/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx b/projects/js-packages/charts/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx index 7835241e5415..2ed234b93b35 100644 --- a/projects/js-packages/charts/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +++ b/projects/js-packages/charts/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx @@ -8,7 +8,7 @@ import clsx from 'clsx'; import { useCallback, useContext, useMemo } from 'react'; import { Legend, useChartLegendItems } from '../../components/legend'; import { BaseTooltip } from '../../components/tooltip'; -import { useElementSize, useInteractiveLegendData, usePrefersReducedMotion } from '../../hooks'; +import { useInteractiveLegendData, usePrefersReducedMotion } from '../../hooks'; import { GlobalChartsProvider, useChartId, @@ -17,12 +17,8 @@ import { GlobalChartsContext, } from '../../providers'; import { attachSubComponents } from '../../utils'; -import { - ChartSVG, - ChartHTML, - useChartChildren, - renderLegendSlot, -} from '../private/chart-composition'; +import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition'; +import { ChartLayout } from '../private/chart-layout'; import { RadialWipeAnimation } from '../private/radial-wipe-animation'; import { SingleChartContext } from '../private/single-chart-context'; import { withResponsive } from '../private/with-responsive'; @@ -181,8 +177,6 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( { const legendPosition = legend.position ?? 'bottom'; const chartId = useChartId( providedChartId ); - // Measure the SVG wrapper to calculate constrained dimensions - const [ svgWrapperRef, svgWrapperWidth, svgWrapperHeight ] = useElementSize< HTMLDivElement >(); const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, hideTooltip, showTooltip } = useTooltip< DataPointPercentage >(); @@ -315,18 +309,6 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( { ); } - // Calculate chart dimensions maintaining the 2:1 width-to-height ratio. - // Use measured SVG wrapper dimensions to respect height constraints, falling back - // to explicit props during initial render before measurement is available. - const availableWidth = svgWrapperWidth > 0 ? svgWrapperWidth : effectiveWidth; - const availableHeight = - svgWrapperHeight > 0 ? svgWrapperHeight : propHeight || effectiveWidth / 2; - // Constrain width so that height (= width / 2) never exceeds the available height - const width = Math.min( availableWidth, availableHeight * 2 ); - const height = width / 2; - const radius = height; // For a semi-circle, radius equals the SVG height - const innerRadius = radius * ( 1 - thickness ); - // Map data with index for color assignment // When interactive, we need to find the original index to maintain consistent colors const dataWithIndex = visibleData.map( d => { @@ -357,16 +339,12 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( { ); return ( - - + = ( { height: propHeight || undefined, } } data-testid="pie-chart-container" + trailingContent={ + <> + { withTooltips && tooltipOpen && tooltipData && ( + +
{ renderTooltip( { tooltipData } ) }
+
+ ) } + { htmlChildren } + { otherChildren } + + } > - { legendPosition === 'top' && legendElement } - { renderLegendSlot( legendChildren, 'top' ) } - -
- - - - - - { /* Main chart group centered horizontally and positioned at bottom */ } - { + // Calculate chart dimensions maintaining the 2:1 width-to-height ratio. + // Use measured dimensions to respect height constraints, falling back + // to explicit props during initial render before measurement is available. + const availableWidth = contentWidth > 0 ? contentWidth : effectiveWidth; + const availableHeight = + contentHeight > 0 ? contentHeight : propHeight || effectiveWidth / 2; + // Constrain width so that height (= width / 2) never exceeds the available height + const width = Math.min( availableWidth, availableHeight * 2 ); + const height = width / 2; + const radius = height; // For a semi-circle, radius equals the SVG height + const innerRadius = radius * ( 1 - thickness ); + + return ( + - { allSegmentsHidden ? ( - - { __( - 'All segments are hidden. Click legend items to show data.', - 'jetpack-charts' - ) } - - ) : ( - <> - { /* Pie chart */ } - - data={ dataWithIndex } - pieValue={ accessors.value } - outerRadius={ radius } + + + - { pie => { - return pie.arcs.map( arc => ( - - - - ) ); - } } - - - { /* Label and note text */ } - - - { label } - - + + + { /* Main chart group centered horizontally and positioned at bottom */ } + + { allSegmentsHidden ? ( + - { note } - - - - { /* Render SVG children from composition API */ } - { ! allSegmentsHidden && svgChildren } - - ) } - - -
- - { legendPosition === 'bottom' && legendElement } - { renderLegendSlot( legendChildren, 'bottom' ) } - - { withTooltips && tooltipOpen && tooltipData && ( - -
{ renderTooltip( { tooltipData } ) }
-
- ) } - - { /* Render HTML children from composition API */ } - { htmlChildren } + { __( + 'All segments are hidden. Click legend items to show data.', + 'jetpack-charts' + ) } +
+ ) : ( + <> + { /* Pie chart */ } + + data={ dataWithIndex } + pieValue={ accessors.value } + outerRadius={ radius } + innerRadius={ innerRadius } + cornerRadius={ 3 } + padAngle={ PAD_ANGLE } + startAngle={ startAngle } + endAngle={ endAngle } + pieSort={ accessors.sort } + > + { pie => { + return pie.arcs.map( arc => ( + + + + ) ); + } } + + + { /* Label and note text */ } + + + { label } + + + { note } + + - { /* Render any other children that aren't compound components */ } - { otherChildren } - + { /* Render SVG children from composition API */ } + { ! allSegmentsHidden && svgChildren } + + ) } +
+ + + ); + } } + ); }; diff --git a/projects/js-packages/charts/src/charts/pie-semi-circle-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/charts/pie-semi-circle-chart/stories/index.stories.tsx index b7f7850dd6d3..b65fa39b869c 100644 --- a/projects/js-packages/charts/src/charts/pie-semi-circle-chart/stories/index.stories.tsx +++ b/projects/js-packages/charts/src/charts/pie-semi-circle-chart/stories/index.stories.tsx @@ -147,12 +147,7 @@ export const WithCompositionLegend: Story = { render: args => { const legend = extractLegendConfig( args ); return ( - + ); diff --git a/projects/js-packages/charts/src/charts/private/chart-layout/chart-layout.module.scss b/projects/js-packages/charts/src/charts/private/chart-layout/chart-layout.module.scss new file mode 100644 index 000000000000..25ba6d73455f --- /dev/null +++ b/projects/js-packages/charts/src/charts/private/chart-layout/chart-layout.module.scss @@ -0,0 +1,7 @@ +// Shared content area for charts using the render prop. +// Wraps render prop output and handles measurement. +.chart-layout__content { + flex: 1; + min-height: 0; + min-width: 0; +} diff --git a/projects/js-packages/charts/src/charts/private/chart-layout/chart-layout.tsx b/projects/js-packages/charts/src/charts/private/chart-layout/chart-layout.tsx new file mode 100644 index 000000000000..5172c5905266 --- /dev/null +++ b/projects/js-packages/charts/src/charts/private/chart-layout/chart-layout.tsx @@ -0,0 +1,114 @@ +import { Stack } from '@wordpress/ui'; +import { forwardRef, useEffect } from 'react'; +import { useElementSize } from '../../../hooks'; +import { renderLegendSlot } from '../chart-composition'; +import styles from './chart-layout.module.scss'; +import type { LegendPosition } from '../../../types'; +import type { LegendChild } from '../chart-composition/use-chart-children'; +import type { GapSize } from '@wordpress/theme'; +import type { CSSProperties, ReactNode } from 'react'; + +/** + * Measurements provided to the render prop when ChartLayout handles resize listening. + */ +export interface ContentMeasurements { + /** Measured width of the content area in pixels */ + contentWidth: number; + /** Measured height of the content area in pixels */ + contentHeight: number; + /** True when a non-zero contentHeight measurement is available */ + isMeasured: boolean; +} + +export interface ChartLayoutProps { + /** Position for the prop-based legend element */ + legendPosition: LegendPosition; + /** The legend element rendered via the showLegend prop (false when hidden) */ + legendElement?: ReactNode; + /** Legend children from the composition API */ + legendChildren: LegendChild[]; + /** Chart content — either a ReactNode or a render prop receiving content measurements */ + children: ReactNode | ( ( measurements: ContentMeasurements ) => ReactNode ); + /** Content rendered after the bottom legend (e.g., nonLegendChildren, htmlChildren, tooltips) */ + trailingContent?: ReactNode; + /** Called when the measured content height changes (for render-prop mode) */ + onContentHeightChange?: ( height: number ) => void; + /** Gap between Stack items */ + gap?: GapSize; + /** Additional class names */ + className?: string; + /** Inline styles (width, height, etc.) */ + style?: CSSProperties; + /** Test ID for the container */ + 'data-testid'?: string; + /** Chart ID attribute */ + 'data-chart-id'?: string; +} + +export const ChartLayout = forwardRef< HTMLDivElement, ChartLayoutProps >( + ( + { + legendPosition, + legendElement, + legendChildren, + children, + trailingContent, + onContentHeightChange, + gap, + className, + style, + 'data-testid': dataTestId, + 'data-chart-id': dataChartId, + }, + ref + ) => { + const [ contentRef, contentWidth, contentHeight ] = useElementSize< HTMLDivElement >(); + const isRenderProp = typeof children === 'function'; + const isMeasured = contentHeight > 0; + + // When using render-prop children, hide the layout until measurement is available + // to prevent layout shift. Plain ReactNode children don't need this since they + // don't depend on measured dimensions. + const visibilityStyle: { visibility?: 'hidden' | 'visible' } = + isRenderProp && ! isMeasured ? { visibility: 'hidden' } : {}; + + useEffect( () => { + if ( isRenderProp && onContentHeightChange && isMeasured ) { + onContentHeightChange( contentHeight ); + } + }, [ isRenderProp, contentHeight, isMeasured, onContentHeightChange ] ); + const renderedChildren = isRenderProp + ? children( { contentWidth, contentHeight, isMeasured } ) + : children; + + return ( + + { legendPosition === 'top' && legendElement } + { renderLegendSlot( legendChildren, 'top' ) } + + { isRenderProp ? ( +
+ { renderedChildren } +
+ ) : ( + renderedChildren + ) } + + { legendPosition === 'bottom' && legendElement } + { renderLegendSlot( legendChildren, 'bottom' ) } + + { trailingContent } +
+ ); + } +); + +ChartLayout.displayName = 'ChartLayout'; diff --git a/projects/js-packages/charts/src/charts/private/chart-layout/index.ts b/projects/js-packages/charts/src/charts/private/chart-layout/index.ts new file mode 100644 index 000000000000..7e4ff79c6c63 --- /dev/null +++ b/projects/js-packages/charts/src/charts/private/chart-layout/index.ts @@ -0,0 +1,2 @@ +export { ChartLayout } from './chart-layout'; +export type { ChartLayoutProps, ContentMeasurements } from './chart-layout'; diff --git a/projects/js-packages/charts/src/charts/private/chart-layout/test/chart-layout.test.tsx b/projects/js-packages/charts/src/charts/private/chart-layout/test/chart-layout.test.tsx new file mode 100644 index 000000000000..394a620cc6d9 --- /dev/null +++ b/projects/js-packages/charts/src/charts/private/chart-layout/test/chart-layout.test.tsx @@ -0,0 +1,177 @@ +import { render, screen } from '@testing-library/react'; +import { renderLegendSlot } from '../../chart-composition'; +import { ChartLayout } from '../chart-layout'; +import type { LegendChild } from '../../chart-composition/use-chart-children'; + +// Mock renderLegendSlot since we test it separately +jest.mock( '../../chart-composition', () => ( { + renderLegendSlot: jest.fn( () => [] ), +} ) ); + +const mockRenderLegendSlot = renderLegendSlot as jest.Mock; + +describe( 'ChartLayout', () => { + beforeEach( () => { + mockRenderLegendSlot.mockReturnValue( [] ); + } ); + + it( 'renders children inside a column Stack', () => { + render( + +
Chart
+
+ ); + expect( screen.getByTestId( 'chart-content' ) ).toBeInTheDocument(); + } ); + + it( 'renders legend element at top when legendPosition is top', () => { + const legendElement =
Legend
; + render( + +
Chart
+
+ ); + const legend = screen.getByTestId( 'legend' ); + const content = screen.getByTestId( 'chart-content' ); + expect( legend.compareDocumentPosition( content ) ).toBe( Node.DOCUMENT_POSITION_FOLLOWING ); + } ); + + it( 'renders legend element at bottom when legendPosition is bottom', () => { + const legendElement =
Legend
; + render( + +
Chart
+
+ ); + const content = screen.getByTestId( 'chart-content' ); + const legend = screen.getByTestId( 'legend' ); + expect( content.compareDocumentPosition( legend ) ).toBe( Node.DOCUMENT_POSITION_FOLLOWING ); + } ); + + it( 'does not render legend element when it is false/null', () => { + render( + +
Chart
+
+ ); + expect( screen.queryByTestId( 'legend' ) ).not.toBeInTheDocument(); + } ); + + it( 'calls renderLegendSlot for both positions', () => { + const legendChildren: LegendChild[] = []; + render( + +
Chart
+
+ ); + expect( mockRenderLegendSlot ).toHaveBeenCalledWith( legendChildren, 'top' ); + expect( mockRenderLegendSlot ).toHaveBeenCalledWith( legendChildren, 'bottom' ); + } ); + + it( 'hides layout until measured when using render-prop children', () => { + // Override the global getBoundingClientRect mock to return zero (unmeasured state) + // for elements inside this test. This simulates the initial state before + // ResizeObserver provides real dimensions in a browser. + const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = function () { + return { width: 0, height: 0, top: 0, left: 0, bottom: 0, right: 0, x: 0, y: 0 } as DOMRect; + }; + + try { + const childFn = jest.fn().mockReturnValue(
Chart
); + render( + + { childFn } + + ); + // When contentHeight is 0, layout should be hidden to prevent layout shift + expect( screen.getByTestId( 'layout' ) ).toHaveStyle( { visibility: 'hidden' } ); + } finally { + Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; + } + } ); + + it( 'does not hide layout when using plain ReactNode children', () => { + render( + +
Chart
+
+ ); + const layoutStyle = screen.getByTestId( 'layout' ).getAttribute( 'style' ) ?? ''; + expect( layoutStyle ).not.toContain( 'visibility' ); + } ); + + it( 'passes className and style to Stack', () => { + render( + +
Chart
+
+ ); + const layout = screen.getByTestId( 'layout' ); + expect( layout ).toHaveClass( 'my-chart' ); + expect( layout ).toHaveStyle( { width: '400px', height: '300px' } ); + } ); + + it( 'passes gap to Stack', () => { + render( + +
Chart
+
+ ); + // Stack renders gap as a CSS class or style — just verify it renders without error + expect( screen.getByTestId( 'layout' ) ).toBeInTheDocument(); + } ); + + it( 'forwards ref to Stack', () => { + const ref = jest.fn(); + render( + +
Chart
+
+ ); + expect( ref ).toHaveBeenCalledWith( expect.any( HTMLElement ) ); + } ); + + it( 'calls function-as-children with measurement props', () => { + const childFn = jest.fn().mockReturnValue(
Chart
); + + render( + + { childFn } + + ); + + expect( childFn ).toHaveBeenCalled(); + const firstCallArg = childFn.mock.calls[ 0 ][ 0 ]; + + expect( firstCallArg ).toEqual( + expect.objectContaining( { + contentWidth: expect.any( Number ), + contentHeight: expect.any( Number ), + isMeasured: expect.any( Boolean ), + } ) + ); + } ); + + it( 'renders trailing content after bottom legend', () => { + render( + Legend
} + legendChildren={ [] } + trailingContent={
Extra
} + > +
Chart
+ + ); + const legend = screen.getByTestId( 'legend' ); + const trailing = screen.getByTestId( 'trailing' ); + expect( legend.compareDocumentPosition( trailing ) ).toBe( Node.DOCUMENT_POSITION_FOLLOWING ); + } ); +} ); diff --git a/projects/js-packages/charts/src/charts/private/single-chart-context/single-chart-context.tsx b/projects/js-packages/charts/src/charts/private/single-chart-context/single-chart-context.tsx index db7c21c4455d..89c19cc6695b 100644 --- a/projects/js-packages/charts/src/charts/private/single-chart-context/single-chart-context.tsx +++ b/projects/js-packages/charts/src/charts/private/single-chart-context/single-chart-context.tsx @@ -13,8 +13,8 @@ export interface ChartInstanceRef { export interface ChartInstanceContextValue { chartId: string; chartRef?: React.RefObject< ChartInstanceRef >; - chartWidth: number; - chartHeight: number; + chartWidth?: number; + chartHeight?: number; } export const ChartInstanceContext = createContext< ChartInstanceContextValue | null >( null ); diff --git a/projects/js-packages/charts/tests/jest.config.cjs b/projects/js-packages/charts/tests/jest.config.cjs index 45b274ece9f0..efbf1dce2b17 100644 --- a/projects/js-packages/charts/tests/jest.config.cjs +++ b/projects/js-packages/charts/tests/jest.config.cjs @@ -18,6 +18,7 @@ module.exports = { transformIgnorePatterns: [ '/node_modules/(?!(\\.pnpm/(d3-|internmap)|d3-|internmap))' ], setupFilesAfterEnv: [ ...( baseConfig.setupFilesAfterEnv || [] ), + path.join( __dirname, 'setup-element-size-mock.js' ), path.join( __dirname, 'setup-visx-tooltip-mock.js' ), ], }; diff --git a/projects/js-packages/charts/tests/setup-element-size-mock.js b/projects/js-packages/charts/tests/setup-element-size-mock.js new file mode 100644 index 000000000000..b2d66cf757c0 --- /dev/null +++ b/projects/js-packages/charts/tests/setup-element-size-mock.js @@ -0,0 +1,73 @@ +/** + * Mock getBoundingClientRect and ResizeObserver so useElementSize returns + * non-zero dimensions in JSDOM. Without this, ChartLayout's render-prop + * visibility guard (visibility: hidden until measured) keeps charts hidden + * and tests can't find accessible elements. + */ + +// Guard for SSR test environment (jest-environment node) where Element is not defined +if ( typeof Element !== 'undefined' ) { + // Return non-zero dimensions from getBoundingClientRect. + // When an element has zero dimensions (JSDOM default), walk up the DOM tree + // to find an ancestor with inline style dimensions. This ensures charts with + // explicit sizes (e.g. Sparkline at 100x40) get realistic measurements rather + // than the hardcoded fallback. + const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = function () { + const rect = originalGetBoundingClientRect.call( this ); + // Only override if all values are zero (JSDOM default) + if ( rect.width === 0 && rect.height === 0 ) { + let width = 0; + let height = 0; + let el = this; + + // Walk up the DOM tree looking for ancestors with inline style dimensions + while ( el ) { + if ( el.style ) { + if ( ! width && el.style.width ) { + width = parseFloat( el.style.width ); + } + if ( ! height && el.style.height ) { + height = parseFloat( el.style.height ); + } + } + if ( width && height ) { + break; + } + el = el.parentElement; + } + + // Fall back to sensible defaults for elements without ancestor dimensions + width = width || 800; + height = height || 400; + + return { + ...rect, + width, + height, + top: 0, + left: 0, + bottom: height, + right: width, + x: 0, + y: 0, + }; + } + return rect; + }; +} + +// Mock ResizeObserver to immediately call the callback +if ( typeof window !== 'undefined' && ! window.ResizeObserver ) { + window.ResizeObserver = class ResizeObserver { + constructor( callback ) { + this._callback = callback; + } + observe() { + // Fire immediately so useElementSize gets dimensions on mount + this._callback(); + } + unobserve() {} + disconnect() {} + }; +} diff --git a/projects/js-packages/charts/tests/setup-visx-tooltip-mock.js b/projects/js-packages/charts/tests/setup-visx-tooltip-mock.js index 5b47c9f234be..6507ab4f45d3 100644 --- a/projects/js-packages/charts/tests/setup-visx-tooltip-mock.js +++ b/projects/js-packages/charts/tests/setup-visx-tooltip-mock.js @@ -1,11 +1,12 @@ /** * Mock for `@visx/tooltip`'s useTooltipInPortal hook to provide valid containerBounds in tests. * - * In JSDOM, getBoundingClientRect() returns zeros since there's no real layout. - * This causes our tooltip positioning guard (which checks containerBounds.width/height > 0) - * to prevent tooltips from showing in tests. + * In JSDOM, react-use-measure (used internally by useTooltipInPortal) cannot update + * bounds because its ResizeObserver callback fires synchronously before the component + * is mounted, failing the internal `mounted.current` guard. This leaves containerBounds + * at {0, 0, ...}, which causes tooltip positioning guards to suppress tooltips. * - * This mock wraps the real hook and overrides containerBounds with non-zero values. + * This mock wraps the real hook and ensures containerBounds always has non-zero values. */ jest.mock( '@visx/tooltip', () => {