import { useCallback, useEffect, useMemo, useRef, useState, useId, type ReactNode, } from 'react' import { BarChart, Bar, Cell, ResponsiveContainer, XAxis, YAxis, Tooltip as RechartsTooltip, type TooltipProps, Area, AreaChart, } from 'recharts' import { ArrowDown, ArrowUp, CircleHelp } from 'lucide-react' import { Tooltip } from '../Tooltip' import { ProgressBar, ProgressBarVariant, ProgressBarSize, } from '../ProgressBar' import Block from '../Primitives/Block/Block' import Text from '../Text/Text' import { ChangeType, StatCardArrowDirection, StatCardDirection, StatCardVariant, type StatCardProps, } from './types' import type { StatCardTokenType } from './statcard.tokens' import { useResponsiveTokens } from '../../hooks/useResponsiveTokens' import { BREAKPOINTS } from '../../breakpoints/breakPoints' import { useBreakpoints } from '../../hooks/useBreakPoints' import { useResizeObserver } from '../../hooks/useResizeObserver' import { SelectMenuSize, SelectMenuVariant, SingleSelect, } from '../SingleSelect' import { toPixels } from '../../global-utils/GlobalUtils' import { getAxisFormatter, createDateTimeFormatter } from '../Charts/ChartUtils' import { AxisType } from '../Charts/types' import StatCardSkeleton from './StatCardSkeleton' import { calculateTrend, getEffectiveChange, shouldShowDecreaseColor, } from './utils' type StatCardHeaderTitleTextProps = { title: string titleId: string statCardToken: StatCardTokenType isSmallScreen: boolean } const StatCardHeaderTitleText = ({ title, titleId, statCardToken, isSmallScreen, }: StatCardHeaderTitleTextProps) => { const [forceTooltipOpen, setForceTooltipOpen] = useState(false) const titleRef = useRef(null) useEffect(() => { if (!isSmallScreen || !forceTooltipOpen) return const handlePointerDown = (event: PointerEvent) => { if (titleRef.current?.contains(event.target as Node)) return setForceTooltipOpen(false) } document.addEventListener('pointerdown', handlePointerDown) return () => document.removeEventListener('pointerdown', handlePointerDown) }, [isSmallScreen, forceTooltipOpen]) const titleText = ( {title} ) if (!isSmallScreen) { return {titleText} } return ( { setForceTooltipOpen(!forceTooltipOpen) }} style={{ cursor: 'pointer' }} > {titleText} ) } type StatCardHelpIconProps = { helpIconText?: string title: string statCardToken: StatCardTokenType isSmallScreen: boolean } const StatCardHelpIcon = ({ helpIconText, title, statCardToken, isSmallScreen, }: StatCardHelpIconProps) => { const [forceTooltipOpen, setForceTooltipOpen] = useState(false) const helpIconRef = useRef(null) useEffect(() => { if (!isSmallScreen || !forceTooltipOpen) return const handlePointerDown = (event: PointerEvent) => { if (helpIconRef.current?.contains(event.target as Node)) return setForceTooltipOpen(false) } document.addEventListener('pointerdown', handlePointerDown) return () => document.removeEventListener('pointerdown', handlePointerDown) }, [isSmallScreen, forceTooltipOpen]) if (!helpIconText) return null const helpIconTrigger = ( ) if (!isSmallScreen) { return ( {helpIconTrigger} ) } return ( { setForceTooltipOpen(!forceTooltipOpen) }} > {helpIconTrigger} ) } type StatCardValueTooltipProps = { content: ReactNode isSmallScreen: boolean children: ReactNode } const StatCardValueTooltip = ({ content, isSmallScreen, children, }: StatCardValueTooltipProps) => { const [open, setOpen] = useState(false) const triggerRef = useRef(null) useEffect(() => { if (!isSmallScreen || !open || !content) return const handlePointerDown = (event: PointerEvent) => { const target = event.target as Node if (triggerRef.current?.contains(target)) return const tooltipEl = document.querySelector('[data-tooltip="tooltip"]') if (tooltipEl?.contains(target)) return setOpen(false) } document.addEventListener('pointerdown', handlePointerDown) return () => document.removeEventListener('pointerdown', handlePointerDown) }, [isSmallScreen, open, content]) if (content === undefined || content === null || content === '') { return <>{children} } if (!isSmallScreen) { return {children} } return ( { event.stopPropagation() setOpen(true) }} > {children} ) } const StatCard = ({ title, value, valueTooltip, change, subtitle, variant, chartData, progressValue, titleIcon, actionIcon, helpIconText, dropdownProps, minWidth = 'auto', maxWidth = 'auto', xAxis, yAxis, valueFormatter, height = 'auto', direction = StatCardDirection.VERTICAL, skeleton, dataDisplay = true, showBorder = true, ...props }: StatCardProps) => { const statCardToken = useResponsiveTokens('STAT_CARD') const { breakPointLabel } = useBreakpoints(BREAKPOINTS) const isSmallScreen = breakPointLabel === 'sm' const titleIconRef = useRef(null) const titleIconWidth = titleIconRef.current?.offsetWidth || 0 const actionIconRef = useRef(null) const [actionIconWidth, setActionIconWidth] = useState(0) useResizeObserver(actionIconRef, (rect) => { setActionIconWidth(rect.width) }) const [hoveredBarIndex, setHoveredBarIndex] = useState(null) const baseId = useId() const titleId = `${baseId}-title` const valueId = `${baseId}-value` const changeId = `${baseId}-change` const subtitleId = `${baseId}-subtitle` const chartId = `${baseId}-chart` const getCardLabel = useCallback( (formatMainValueFn: (val: string | number) => string) => { const parts: string[] = [] if (title) parts.push(title) if (value !== undefined && value !== null && value !== '') { parts.push(formatMainValueFn(value)) } if (subtitle) parts.push(subtitle) if ( change && change.value != null && change.value != undefined && typeof change.value === 'number' && change.valueType != null && change.valueType != undefined ) { const changeText = change.valueType === ChangeType.INCREASE ? `increased by ${change.value.toFixed(2)}%` : `decreased by ${change.value.toFixed(2)}%` parts.push(changeText) } return parts.join(', ') }, [title, value, subtitle, change] ) // Construct accessible description for charts const chartLabel = useMemo(() => { if (!chartData || chartData.length === 0) return undefined const dataPoints = chartData.length const firstValue = chartData[0]?.value const lastValue = chartData[chartData.length - 1]?.value const trend = firstValue && lastValue && lastValue > firstValue ? 'increasing' : firstValue && lastValue && lastValue < firstValue ? 'decreasing' : 'stable' return `${title} chart showing ${dataPoints} data points with ${trend} trend` }, [chartData, title]) const formatTooltipLabel = (label: string | number): string => { if (!xAxis) return String(label) if (xAxis.tickFormatter) return xAxis.tickFormatter(label) if (xAxis.type === AxisType.DATE_TIME) { // For tooltips, always show full date/time with year const tooltipFormatter = createDateTimeFormatter({ useUTC: true, smartDateTimeFormat: false, showYear: true, }) return tooltipFormatter(label) } if (xAxis.type) { return getAxisFormatter(xAxis)(label) } return String(label) } const formatTooltipValue = (val: string | number): string => { if (!yAxis) { return typeof val === 'number' ? val.toLocaleString() : String(val) } if (yAxis.tickFormatter) return yAxis.tickFormatter(val) if (yAxis.type) { return getAxisFormatter(yAxis)(val) } return typeof val === 'number' ? val.toLocaleString() : String(val) } const formatMainValue = useCallback( (val: string | number): string => { if (valueFormatter) { return getAxisFormatter({ type: valueFormatter })(val) } return String(val) }, [valueFormatter] ) const cardLabel = useMemo( () => getCardLabel(formatMainValue), [getCardLabel, formatMainValue] ) const { label, placeholder, items, selected, onSelect } = dropdownProps || {} const gradientId = useMemo( () => `colorGradient-${Math.random().toString(36).slice(2, 11)}`, [] ) const normalizedVariant = variant === StatCardVariant.PROGRESS_BAR ? 'progress' : variant const isTrendingDown = calculateTrend(chartData) const effectiveChange = getEffectiveChange(change, chartData) const arrowDirection = (change && change.arrowDirection) ?? (effectiveChange?.valueType === ChangeType.INCREASE ? StatCardArrowDirection.UP : StatCardArrowDirection.DOWN) // Only render if effectiveChange is not null/undefined and value is a valid number (including 0) const shouldRenderChange = effectiveChange != null && effectiveChange.value != undefined && effectiveChange.value != null && typeof effectiveChange.value === 'number' && !Number.isNaN(effectiveChange.value) const formattedChange = shouldRenderChange && effectiveChange ? ( {arrowDirection === StatCardArrowDirection.UP ? ( ) : ( )} {effectiveChange.value!.toFixed(2)?.includes('-') ? '' : '+'} {effectiveChange.value!.toFixed(2)}% ) : null const shouldShowDecrease = shouldShowDecreaseColor( effectiveChange, isTrendingDown ) const lineColor = shouldShowDecrease ? statCardToken.chart.colors.line.decrease : statCardToken.chart.colors.line.increase const baseAreaColor = shouldShowDecrease ? statCardToken.chart.colors.area.decrease : statCardToken.chart.colors.area.increase const hexToRgba = (hex: string, alpha: number) => { const r = parseInt(hex.slice(1, 3), 16) const g = parseInt(hex.slice(3, 5), 16) const b = parseInt(hex.slice(5, 7), 16) return `rgba(${r}, ${g}, ${b}, ${alpha})` } const areaColor = typeof baseAreaColor === 'string' ? hexToRgba( baseAreaColor, statCardToken.chart.colors.gradient.startOpacity ) : baseAreaColor const CustomTooltip = ({ active, payload, }: TooltipProps) => { if (!active || !payload || payload.length === 0) return null const payloadItem = payload[0] if (!payloadItem) return null const currentValue = payloadItem.value as number const name = payloadItem.payload?.name return ( {`${formatTooltipLabel(name || '')},`} {formatTooltipValue(currentValue)} ) } const chartFallbackStyle = { paddingLeft: titleIconWidth + toPixels(statCardToken.textContainer.header.gap), width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', } const indexedChartData = useMemo(() => { return chartData?.map((point, index) => ({ ...point, index, })) }, [chartData]) const handleBarMouseEnter = useCallback( (data: { index: number }) => { if (data.index !== hoveredBarIndex) { setHoveredBarIndex(data.index) } }, [hoveredBarIndex] ) const handleBarMouseLeave = useCallback(() => { setHoveredBarIndex(null) }, []) if (!value && !change && !progressValue && !chartData?.length) { return ( {titleIcon && !isSmallScreen && ( {titleIcon} )} {'--'} {!isSmallScreen && ( {subtitle} )} {isSmallScreen && items && items.length > 0 && ( {})} variant={SelectMenuVariant.NO_CONTAINER} size={SelectMenuSize.SMALL} inline={true} minMenuWidth={100} /> )} ) } return ( {skeleton?.show ? ( ) : ( <> {variant !== StatCardVariant.NUMBER && ( {direction === StatCardDirection.VERTICAL && ( <> {titleIcon && !isSmallScreen && ( {titleIcon} )} )} {direction === StatCardDirection.VERTICAL && ( )} {actionIcon && direction !== StatCardDirection.HORIZONTAL && ( {actionIcon} )} {direction === StatCardDirection.HORIZONTAL && ( {titleIcon && ( {titleIcon} )} {actionIcon && ( {actionIcon} )} )} {formatMainValue(value || '--')} {formattedChange && ( {formattedChange} )} {!isSmallScreen && subtitle && ( {subtitle} )} {isSmallScreen && items && items.length > 0 && ( {}) } variant={ SelectMenuVariant.NO_CONTAINER } size={SelectMenuSize.SMALL} inline={true} minMenuWidth={100} /> )} )} {variant === StatCardVariant.NUMBER && ( {actionIcon && ( {actionIcon} )} {titleIcon && !isSmallScreen && ( {titleIcon} )} {formatMainValue(value || '--')} {formattedChange && ( {formattedChange} )} {!isSmallScreen && subtitle && ( {subtitle} )} {isSmallScreen && items && items.length > 0 && ( {}) } variant={ SelectMenuVariant.NO_CONTAINER } size={SelectMenuSize.SMALL} inline={true} minMenuWidth={100} /> )} {indexedChartData && ( {indexedChartData && indexedChartData.length > 0 ? ( } // cursor={{ // strokeDasharray: // statCardToken.chart.tooltip // .cursor.strokeDasharray, // stroke: statCardToken.chart.tooltip // .cursor.stroke, // }} position={{ y: 0 }} isAnimationActive={false} animationDuration={350} /> ) : ( -- )} )} )} {variant !== StatCardVariant.NUMBER && dataDisplay && ( {variant === StatCardVariant.LINE && (indexedChartData && indexedChartData.length > 0 ? ( } // cursor={{ // strokeDasharray: // statCardToken.chart.tooltip // .cursor.strokeDasharray, // stroke: statCardToken.chart.tooltip // .cursor.stroke, // }} position={{ y: 0 }} isAnimationActive={false} animationDuration={350} /> ) : ( -- ))} {variant === StatCardVariant.BAR && (indexedChartData && indexedChartData.length > 0 ? ( } cursor={{ fill: 'transparent', }} position={{ y: 0 }} isAnimationActive={false} /> {indexedChartData?.map( (_, index) => ( ) )} ) : ( -- ))} {variant === StatCardVariant.PROGRESS_BAR && (progressValue ? ( ) : ( -- ))} )} )} ) } StatCard.displayName = 'StatCard' export default StatCard