import { classnames, PropTypes, React } from '@gravityforms/libraries';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Rectangle } from 'recharts';
import Box from '../../../elements/Box';
import Checkbox from '../../../elements/Checkbox';
import { buildTooltipAttributes } from '../common/Tooltip';
const { forwardRef, useState, useEffect } = React;
/**
* @function formatPercentageTick
* @description Formats a normalized axis value as a percentage.
*
* @since 6.6.3
*
* @param {number} value The axis tick value.
*
* @return {string} The formatted percentage label.
*/
const formatPercentageTick = ( value ) => `${ ( value * 100 ).toFixed( 0 ) }%`;
/**
* @function formatPercentageValue
* @description Formats a normalized tooltip value as a percentage.
*
* @since 6.6.3
*
* @param {number} value The tooltip value.
*
* @return {string} The formatted percentage label.
*/
const formatPercentageValue = ( value ) => `${ ( value * 100 ).toFixed( 1 ) }%`;
/**
* @function resolveStackId
* @description Resolves the stack ID for a series based on option and chart settings.
*
* @since 6.6.3
*
* @param {Object} option The series option.
* @param {boolean} stacked Whether the chart is in stacked mode.
* @param {boolean} normalized Whether the chart is in normalized mode.
*
* @return {string|undefined} The stack ID, if any.
*/
const resolveStackId = ( option, stacked, normalized ) => {
if ( Object.prototype.hasOwnProperty.call( option, 'stackId' ) ) {
return option.stackId || undefined;
}
if ( stacked || normalized ) {
return 'stack';
}
return undefined;
};
/**
* @function resolveSeriesActiveBar
* @description Resolves per-series activeBar behavior for Recharts and custom hover shaping.
*
* @since 6.6.3
*
* @param {Object} option The series option.
*
* @return {Object} The resolved activeBar configuration.
*/
const resolveSeriesActiveBar = ( option ) => {
if ( option.activeBar === false ) {
return {
activeBar: false,
useCustomShapeHover: false,
};
}
if ( option.activeBar && typeof option.activeBar === 'object' ) {
return {
activeBar: option.activeBar,
useCustomShapeHover: false,
};
}
return {
activeBar: false,
useCustomShapeHover: true,
};
};
/**
* @function extractBarPassThroughProps
* @description Separates chart-managed and event-handler props from other Bar pass-through props.
*
* @since 6.6.3
*
* @param {Object} globalBarProps Bar props applied to every series.
* @param {Object} seriesBarProps Per-series Bar props.
*
* @return {Object} Pass-through props and event handlers.
*/
const extractBarPassThroughProps = ( globalBarProps = {}, seriesBarProps = {} ) => {
const mergedBarProps = {
...globalBarProps,
...seriesBarProps,
};
const {
// remove activeBar from the pass-through props.
activeBar, // eslint-disable-line no-unused-vars
onClick,
onMouseEnter,
onMouseLeave,
onMouseDown,
onMouseUp,
...passThroughProps
} = mergedBarProps;
return {
passThroughHandlers: {
onClick,
onMouseEnter,
onMouseLeave,
onMouseDown,
onMouseUp,
},
passThroughProps,
};
};
/**
* @function composeBarEventHandler
* @description Composes Recharts pass-through handlers with chart-level callbacks.
*
* @since 6.6.3
*
* @param {string} dataKey The data key of the bar series.
* @param {Function|null} passThroughHandler Recharts handler from barProps/option.barProps.
* @param {Function|null} chartCallback Chart-level callback prop.
* @param {Function|null} sideEffect Optional internal side effect.
*
* @return {Function} The composed event handler for a Bar series.
*/
const composeBarEventHandler = ( dataKey, passThroughHandler, chartCallback, sideEffect = null ) => ( barData, index, event ) => {
if ( sideEffect ) {
sideEffect( barData, index, event );
}
if ( passThroughHandler ) {
passThroughHandler( barData, index, event );
}
if ( ! chartCallback ) {
return;
}
chartCallback( {
data: barData,
index,
dataKey,
event,
} );
};
/**
* @function createHoveredBarShape
* @description Creates a bar shape renderer that highlights only the hovered bar segment.
*
* @since 6.6.3
*
* @param {Object} option The series option.
* @param {Object|null} hoveredBar The currently hovered bar state.
*
* @return {Function} Recharts bar shape renderer.
*/
const createHoveredBarShape = ( option, hoveredBar ) => ( props ) => {
const { index } = props;
const isHovered = hoveredBar?.dataKey === option.dataKey && hoveredBar?.index === index;
const fill = isHovered ? ( option.hoverColor || option.color ) : option.color;
return (
<Rectangle
{ ...props }
fill={ fill }
{ ...( option.radius ? { radius: option.radius } : {} ) }
/>
);
};
/**
* @function applySeriesBarLabel
* @description Applies a per-series barLabel override, including explicit false values.
*
* @since 6.6.3
*
* @param {Object} barAttributes The Recharts Bar props being assembled.
* @param {Object} option The series option.
*
* @return {void}
*/
const applySeriesBarLabel = ( barAttributes, option ) => {
if ( Object.prototype.hasOwnProperty.call( option, 'barLabel' ) ) {
barAttributes.label = option.barLabel;
}
};
/**
* @function resolveBarShape
* @description Resolves the Bar shape renderer with explicit precedence.
*
* @since 6.6.3
*
* @param {Object} option The series option.
* @param {boolean} useCustomShapeHover Whether custom hover shaping is enabled.
* @param {Function} passThroughShape Shape from barProps/option.barProps, if any.
* @param {Object|null} hoveredBar The currently hovered bar state.
*
* @return {Function|null} The resolved shape renderer, if one should be applied.
*/
const resolveBarShape = ( option, useCustomShapeHover, passThroughShape, hoveredBar ) => {
if ( option.shape ) {
return option.shape;
}
if ( useCustomShapeHover && ! passThroughShape ) {
return createHoveredBarShape( option, hoveredBar );
}
return null;
};
/**
* @function buildBarSeriesAttributes
* @description Builds Recharts Bar props for a series with documented merge precedence.
*
* Precedence (lowest to highest):
* 1. Component defaults (animation, dataKey, fill, name)
* 2. Per-series option fields (stackId, radius, legendType)
* 3. barProps / option.barProps pass-through (excluding activeBar and event handlers)
* 4. Chart-managed props (activeBar, composed event handlers)
* 5. option.barLabel (when defined, including false)
* 6. option.shape, then custom hover shape when enabled
*
* @since 6.6.3
*
* @param {Object} config Bar build configuration.
* @param {Object} config.option The series option.
* @param {boolean} config.stacked Whether the chart is stacked.
* @param {boolean} config.normalized Whether the chart is normalized.
* @param {number} config.animationDuration Animation duration in milliseconds.
* @param {boolean} config.animationActive Whether animation is active.
* @param {Object} config.barProps Global Bar pass-through props.
* @param {Object|null} config.hoveredBar The currently hovered bar state.
* @param {Function} config.setHoveredBar Setter for hovered bar state.
* @param {Function|null} config.onBarClick Chart-level bar click callback.
* @param {Function|null} config.onMouseEnter Chart-level mouse enter callback.
* @param {Function|null} config.onMouseLeave Chart-level mouse leave callback.
* @param {Function|null} config.onMouseDown Chart-level mouse down callback.
* @param {Function|null} config.onMouseUp Chart-level mouse up callback.
*
* @return {Object} Recharts Bar props for the series.
*/
const buildBarSeriesAttributes = ( {
option,
stacked,
normalized,
animationDuration,
animationActive,
barProps,
hoveredBar,
setHoveredBar,
onBarClick,
onMouseEnter,
onMouseLeave,
onMouseDown,
onMouseUp,
} ) => {
const stackId = resolveStackId( option, stacked, normalized );
const { activeBar, useCustomShapeHover } = resolveSeriesActiveBar( option );
const { passThroughHandlers, passThroughProps } = extractBarPassThroughProps(
barProps,
option.barProps || {}
);
const barAttributes = {
animationBegin: 0,
animationDuration,
dataKey: option.dataKey,
fill: option.color,
isAnimationActive: animationActive,
name: option.name || option.label,
...( stackId ? { stackId } : {} ),
...( option.radius ? { radius: option.radius } : {} ),
...( option.legendType ? { legendType: option.legendType } : {} ),
...passThroughProps,
activeBar,
onClick: composeBarEventHandler(
option.dataKey,
passThroughHandlers.onClick,
onBarClick
),
onMouseDown: composeBarEventHandler(
option.dataKey,
passThroughHandlers.onMouseDown,
onMouseDown
),
onMouseEnter: composeBarEventHandler(
option.dataKey,
passThroughHandlers.onMouseEnter,
onMouseEnter,
( barData, index ) => setHoveredBar( { dataKey: option.dataKey, index } )
),
onMouseLeave: composeBarEventHandler(
option.dataKey,
passThroughHandlers.onMouseLeave,
onMouseLeave,
() => setHoveredBar( null )
),
onMouseUp: composeBarEventHandler(
option.dataKey,
passThroughHandlers.onMouseUp,
onMouseUp
),
};
applySeriesBarLabel( barAttributes, option );
const resolvedShape = resolveBarShape(
option,
useCustomShapeHover,
barAttributes.shape,
hoveredBar
);
if ( resolvedShape ) {
barAttributes.shape = resolvedShape;
}
return barAttributes;
};
/**
* @module GravityBarChart
* @description The BarChart component. Loaded by the Chart renderer and displayed by passing type "bar".
*
* @since 6.6.3
*
* @param {number} animationDuration The duration of the reveal animation in milliseconds.
* @param {object} barChartProps The props to pass to the BarChart container (can also override the presets we use). Check Recharts docs for all available.
* @param {object} barProps The props to pass to each Bar (can also override the presets we use). Recharts event handlers are composed with the chart-level callback props rather than replaced. Check Recharts docs for all available.
* @param {object} cartesianGridProps The props to pass to the CartesianGrid (can also override the presets we use). Check Recharts docs for all available.
* @param {object} checkboxProps The props to pass to each Checkbox. Check our docs for all available.
* @param {node} children Any additional content to display below the chart.
* @param {object} customAttributes Custom attributes to apply to the chart wrapper.
* @param {string|array|object} customClasses Custom classes to apply to the chart wrapper.
* @param {Function} customInterval Custom function to calculate the x-axis tick interval.
* @param {array} data The data to display in the chart. Check Recharts docs for formats.
* @param {string} gridColor The color of the grid lines and the x and y axis.
* @param {number|string} height The height of the chart.
* @param {object} legendProps The props to pass to the Legend (can also override the presets we use). Check Recharts docs for all available.
* @param {boolean} normalized Whether to render a normalized (100%) stacked chart.
* @param {Function} onBarClick Callback fired when a bar is clicked. Receives { data, index, dataKey, event }.
* @param {Function} onMouseEnter Callback fired when a bar is hovered. Receives { data, index, dataKey, event }.
* @param {Function} onMouseLeave Callback fired when the mouse leaves a bar. Receives { data, index, dataKey, event }.
* @param {Function} onMouseDown Callback fired when a bar is pressed. Receives { data, index, dataKey, event }.
* @param {Function} onMouseUp Callback fired when a bar press ends. Receives { data, index, dataKey, event }.
* @param {array} options The options to display as checkboxes to toggle the visibility of each data set. Supports per-series color, hoverColor, stackId, name, legendType, hide, radius, activeBar (object or false), barLabel, and barProps (Recharts event handlers are composed with chart-level callbacks).
* @param {boolean} showCheckboxes Whether to show the checkboxes to toggle the visibility of each data set.
* @param {boolean} showLegend Whether to show the legend.
* @param {boolean} stacked Whether to stack bars or display them side by side.
* @param {object} tooltipProps The props to pass to the Tooltip (can also override the presets we use). Check Recharts docs for all available.
* @param {boolean} tooltipShared Whether the tooltip is shared across series at the same category.
* @param {number|string} width The width of the chart.
* @param {object} xAxisProps The props to pass to the XAxis (can also override the presets we use). Check Recharts docs for all available.
* @param {object} yAxisProps The props to pass to the YAxis (can also override the presets we use). Check Recharts docs for all available.
* @param {object} ref The reference to the chart component.
*
* @return {JSX.Element|null} The BarChart component.
*/
const GravityBarChart = forwardRef( ( {
animationDuration = 1000,
barChartProps = {},
barProps = {},
cartesianGridProps = {},
checkboxProps = {},
children = null,
customAttributes = {},
customClasses = {},
customInterval = null,
data = [],
gridColor = '#ecedf8',
height = 400,
legendProps = {},
normalized = false,
onBarClick = null,
onMouseEnter = null,
onMouseLeave = null,
onMouseDown = null,
onMouseUp = null,
options = [],
showCheckboxes = true,
showLegend = false,
stacked = false,
tooltipProps = {},
tooltipShared = false,
width = '100%',
xAxisProps = {},
yAxisProps = {},
}, ref ) => {
const componentProps = {
className: classnames( {
'gform-chart--bar': true,
'gform-chart__wrapper': true,
}, customClasses ),
...customAttributes,
ref,
};
const visibleOptions = options.filter( ( option ) => ! option.hide );
const initialCheckboxState = visibleOptions.reduce( ( acc, option ) => {
acc[ option.dataKey ] = option.defaultChecked ?? true;
return acc;
}, {} );
const [ checkboxState, setCheckboxState ] = useState( initialCheckboxState );
const [ animationActive, setAnimationActive ] = useState( true );
const [ hoveredBar, setHoveredBar ] = useState( null );
const [ interval, setInterval ] = useState( Math.floor( data.length / 10 ) );
/**
* @function updateInterval
* @description Updates the x-axis tick interval based on viewport width.
*
* @since 6.6.3
*
* @return {void}
*/
const updateInterval = () => {
const screenWidth = window.innerWidth;
setInterval( Math.floor( data.length / ( screenWidth / 180 ) ) );
};
useEffect( () => {
const timer = setTimeout( () => {
setAnimationActive( false );
}, animationDuration );
return () => clearTimeout( timer );
}, [] );
useEffect( () => {
if ( customInterval ) {
customInterval( setInterval, data.length );
} else {
updateInterval();
window.addEventListener( 'resize', updateInterval );
return () => {
window.removeEventListener( 'resize', updateInterval );
};
}
}, [ data.length, customInterval ] );
/**
* @function handleCheckboxChange
* @description Toggles visibility of a data series via checkbox.
*
* @since 6.6.3
*
* @param {string} dataKey The data key to toggle.
*
* @return {void}
*/
const handleCheckboxChange = ( dataKey ) => {
setCheckboxState( ( prevState ) => ( {
...prevState,
[ dataKey ]: ! prevState[ dataKey ],
} ) );
};
const cartesianGridAttributes = {
stroke: gridColor,
strokeDasharray: '0',
vertical: false,
...cartesianGridProps,
};
const yAxisAttributes = {
axisLine: { stroke: gridColor },
padding: { top: 10 },
tickLine: { stroke: gridColor },
...( normalized && ! yAxisProps.tickFormatter ? { tickFormatter: formatPercentageTick } : {} ),
...yAxisProps,
};
const xAxisAttributes = {
axisLine: { stroke: gridColor },
tickLine: { stroke: gridColor },
interval,
...xAxisProps,
};
const tooltipAttributes = buildTooltipAttributes( {
cursor: false,
defaultValueFormatter: normalized && ! tooltipProps.formatter ? formatPercentageValue : null,
tooltipProps,
tooltipShared,
} );
const legendAttributes = {
...legendProps,
};
const barChartAttributes = {
accessibilityLayer: true,
data,
margin: { top: 0, right: 20, left: 0, bottom: 0 },
...( normalized && ! barChartProps.stackOffset ? { stackOffset: 'expand' } : {} ),
...barChartProps,
};
return (
<div { ...componentProps }>
{ showCheckboxes && (
<div className="gform-chart__checkboxes">
<Box display="flex" spacing={ [ 0, 5, 0, 0 ] }>
{ visibleOptions.map( ( option ) => {
const checkboxAttributes = {
externalChecked: checkboxState[ option.dataKey ],
externalControl: true,
labelAttributes: {
label: option.label,
weight: 'normal',
},
onChange: () => handleCheckboxChange( option.dataKey ),
spacing: [ 0, 0, 4, 3 ],
...checkboxProps,
};
return (
<Checkbox key={ option.dataKey } { ...checkboxAttributes } />
);
} ) }
</Box>
</div>
) }
<ResponsiveContainer width={ width } height={ height }>
<BarChart { ...barChartAttributes }>
<CartesianGrid { ...cartesianGridAttributes } />
<XAxis { ...xAxisAttributes } />
<YAxis { ...yAxisAttributes } />
<Tooltip { ...tooltipAttributes } />
{ showLegend && <Legend { ...legendAttributes } /> }
{ options.map(
( option ) => {
if ( option.hide || ! checkboxState[ option.dataKey ] ) {
return null;
}
const barAttributes = buildBarSeriesAttributes( {
option,
stacked,
normalized,
animationDuration,
animationActive,
barProps,
hoveredBar,
setHoveredBar,
onBarClick,
onMouseEnter,
onMouseLeave,
onMouseDown,
onMouseUp,
} );
return (
<Bar key={ option.dataKey } { ...barAttributes } />
);
}
) }
</BarChart>
</ResponsiveContainer>
{ children }
</div>
);
} );
GravityBarChart.propTypes = {
animationDuration: PropTypes.number,
barChartProps: PropTypes.object,
barProps: PropTypes.object,
cartesianGridProps: PropTypes.object,
checkboxProps: PropTypes.object,
children: PropTypes.oneOfType( [
PropTypes.arrayOf( PropTypes.node ),
PropTypes.node,
] ),
customAttributes: PropTypes.object,
customClasses: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.array,
PropTypes.object,
] ),
customInterval: PropTypes.func,
data: PropTypes.array,
gridColor: PropTypes.string,
height: PropTypes.oneOfType( [ PropTypes.string, PropTypes.number ] ),
legendProps: PropTypes.object,
normalized: PropTypes.bool,
onBarClick: PropTypes.func,
onMouseEnter: PropTypes.func,
onMouseLeave: PropTypes.func,
onMouseDown: PropTypes.func,
onMouseUp: PropTypes.func,
options: PropTypes.array,
showCheckboxes: PropTypes.bool,
showLegend: PropTypes.bool,
stacked: PropTypes.bool,
tooltipProps: PropTypes.object,
tooltipShared: PropTypes.bool,
width: PropTypes.oneOfType( [ PropTypes.string, PropTypes.number ] ),
xAxisProps: PropTypes.object,
yAxisProps: PropTypes.object,
};
GravityBarChart.displayName = 'BarChart';
export default GravityBarChart;