UNPKG

@gravityforms/components

Version:

UI components for use in Gravity Forms development. Both React and vanilla js flavors.

624 lines (575 loc) 20.1 kB
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;