modules_Chart_BarChart_index.js

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;