import { ArrowDown, RotateCcw, ArrowUp } from 'lucide-react' import { useResizeObserver } from '../../hooks/useResizeObserver' import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react' import { useDebounce } from '../../hooks/useDebounce' import { ChartLegendsProps, StackedLegendsDataPoint } from './types' import Block from '../../components/Primitives/Block/Block' import Text from '../../components/Text/Text' import { ChartTokensType } from './chart.tokens' import { FOUNDATION_THEME } from '../../tokens' import { getColorByKey } from './utils' import { useResponsiveTokens } from '../../hooks/useResponsiveTokens' import { Tag, TagColor, TagVariant } from '../Tags' import PrimitiveButton from '../Primitives/PrimitiveButton/PrimitiveButton' import { Menu } from '../Menu' import { Button, ButtonSize, ButtonSubType, ButtonType } from '../Button' import { Skeleton } from '../Skeleton' const StackedLegends: React.FC<{ keys: string[] activeKeys: string[] | null handleLegendClick: (dataKey: string) => void colors: string[] handleLegendEnter: (dataKey: string) => void handleLegendLeave: () => void hoveredKey: string | null selectedKeys: string[] isSmallScreen: boolean stackedLegendsData: StackedLegendsDataPoint[] }> = ({ keys, activeKeys, handleLegendClick, colors, handleLegendEnter, handleLegendLeave, hoveredKey, selectedKeys, isSmallScreen, stackedLegendsData, }) => { const chartTokens = useResponsiveTokens('CHARTS') const legendTokens = chartTokens.content.legend const getItemOpacity = (key: string) => { if (hoveredKey) { return hoveredKey === key ? 1 : 0.4 } if (selectedKeys && selectedKeys.length > 0) { return selectedKeys.includes(key) ? 1 : 0.4 } return 1 } return ( {keys.map((key, index) => { const itemColor = getColorByKey(key, colors, index) return ( handleLegendClick(key), onMouseEnter: () => handleLegendEnter(key), onMouseLeave: handleLegendLeave, opacity: getItemOpacity(key), })} > handleLegendClick(key)} onMouseEnter={() => handleLegendEnter(key)} onMouseLeave={handleLegendLeave} onFocus={() => handleLegendEnter(key)} onBlur={handleLegendLeave} style={{ display: 'flex', alignItems: 'center', gap: typeof legendTokens.item.gap === 'string' ? legendTokens.item.gap : `${legendTokens.item.gap}px`, height: FOUNDATION_THEME.unit[16], cursor: 'pointer', transition: 'all 300ms', opacity: getItemOpacity(key), background: 'none', border: 'none', padding: 0, fontFamily: 'inherit', fontSize: 'inherit', color: 'inherit', }} _focusVisible={{ outline: '3px solid #BEDBFF', border: '1px solid #0561E2', cursor: 'pointer', outlineOffset: '2px', }} > {key} {isSmallScreen && ( {stackedLegendsData?.[index] ? `${stackedLegendsData[index].value.toFixed(2)}%` : '0.00%'} ) : ( ) } /> )} ) })} ) } const ChartLegendsComponent: React.FC = ({ legends, keys, handleLegendClick, handleLegendEnter, handleLegendLeave, colors, chartContainerRef, selectedKeys, setSelectedKeys, hoveredKey, stacked = false, isSmallScreen = false, stackedLegendsData, showAllLegends = false, }) => { const chartTokens = useResponsiveTokens('CHARTS') const legendTokens = chartTokens.content.legend const displayLegends = useMemo( () => legends || keys.map((key) => ({ title: key })), [legends, JSON.stringify(keys)] ) const lastWidth = useRef(0) const legendItemsContainerRef = useRef(null!) const [cuttOffIndex, setCuttOffIndex] = useState(keys.length) const [isLoading, setIsLoading] = useState(true) const isExpanding = useRef(false) // Have to revisit from optimizaion POV. const handleResize = useCallback(() => { if (showAllLegends) { setCuttOffIndex(displayLegends.length) setIsLoading(false) return } if (!legendItemsContainerRef.current) { setIsLoading(false) return } const container = legendItemsContainerRef.current const containerRect = container.getBoundingClientRect() const containerWidth = containerRect.width const BUFFER = 30 const MORE_BUTTON_ESTIMATED_WIDTH = 80 const GAP_SIZE = typeof legendTokens.gap === 'string' ? parseFloat(legendTokens.gap) : (legendTokens.gap as number) || 16 const legendItems = Array.from(container.children) const totalLegends = displayLegends.length if (isExpanding.current && legendItems.length < totalLegends) { setCuttOffIndex(totalLegends) isExpanding.current = false setTimeout(() => { handleResize() }, 50) return } let totalWidth = 0 let optimalCutoff = 0 for (let i = 0; i < Math.min(legendItems.length, totalLegends); i++) { const itemWidth = ( legendItems[i] as HTMLElement ).getBoundingClientRect().width const totalGaps = i > 0 ? i * GAP_SIZE : 0 const remainingItems = totalLegends - (i + 1) const needsMoreButton = remainingItems > 0 const requiredSpace = totalWidth + itemWidth + totalGaps + (needsMoreButton ? MORE_BUTTON_ESTIMATED_WIDTH + GAP_SIZE : 0) + BUFFER if (requiredSpace <= containerWidth) { totalWidth += itemWidth optimalCutoff = i + 1 } else { break } } const newCutoff = Math.max(1, Math.min(optimalCutoff, totalLegends)) setCuttOffIndex(newCutoff) setIsLoading(false) }, [displayLegends, legendTokens.gap, showAllLegends]) const debouncedResize = useDebounce(handleResize, 150) useResizeObserver(chartContainerRef, ({ width }) => { if (showAllLegends) { lastWidth.current = width return } if (width && Math.abs(width - lastWidth.current) > 10) { setIsLoading(true) if (width > lastWidth.current + 20) { isExpanding.current = true } lastWidth.current = width debouncedResize() } }) useEffect(() => { if (showAllLegends) { setIsLoading(false) setCuttOffIndex(displayLegends.length) isExpanding.current = false return } setIsLoading(true) setCuttOffIndex(displayLegends.length) isExpanding.current = false const timeoutId = setTimeout(() => { handleResize() }, 150) return () => clearTimeout(timeoutId) }, [displayLegends, handleResize, showAllLegends]) const getItemOpacity = (dataKey: string) => { if (hoveredKey) { return hoveredKey === dataKey ? 1 : 0.4 } if (selectedKeys && selectedKeys.length > 0) { return selectedKeys.includes(dataKey) ? 1 : 0.4 } return 1 } if (stacked) { const normalizedColors = colors.map((color) => typeof color === 'string' ? color : color.color ) return ( ) } const ChartLegendSkeleton = ({ count = 5 }: { count?: number }) => { return ( {Array.from({ length: count }).map((_, i) => ( ))} ) } return ( {isLoading && ( )} {displayLegends .slice(0, cuttOffIndex) .map( ( legend: { title: string; total?: number }, index: number ) => { const dataKey = legend.title const itemColor = getColorByKey( dataKey, colors, index ) return ( handleLegendClick(dataKey)} onMouseEnter={() => handleLegendEnter(dataKey) } onMouseLeave={handleLegendLeave} onFocus={() => handleLegendEnter(dataKey)} onBlur={handleLegendLeave} style={{ display: 'flex', alignItems: 'center', gap: typeof legendTokens.item.gap === 'string' ? legendTokens.item.gap : `${legendTokens.item.gap}px`, height: FOUNDATION_THEME.unit[16], cursor: 'pointer', transition: 'all 300ms', opacity: getItemOpacity(dataKey), background: 'none', border: 'none', padding: 0, fontFamily: 'inherit', fontSize: 'inherit', color: 'inherit', }} _focusVisible={{ outline: '3px solid #BEDBFF', border: '1px solid #0561E2', cursor: 'pointer', outlineOffset: '2px', }} > {legend.total !== undefined ? ( {dataKey} I {legend.total} ) : ( {dataKey} )} ) } )} {cuttOffIndex < displayLegends.length && ( } items={[ { items: displayLegends.slice(cuttOffIndex).map( ( legend: { title: string total?: number }, index: number ) => { const dataKey = legend.title // Try to find color by key first, fallback to index const colorByKey = colors.find( (c) => typeof c !== 'string' && c.key === dataKey ) // Extract color string - prioritize key match, then fallback to index let itemColor: string if (colorByKey) { itemColor = colorByKey.color } else { const fallbackColor = colors[index % colors.length] itemColor = typeof fallbackColor === 'string' ? fallbackColor : fallbackColor?.color || '#000000' } const isHovered = hoveredKey === dataKey const isSelected = selectedKeys.includes(dataKey) const itemOpacity = isHovered ? 1 : getItemOpacity(dataKey) return { label: '', slot1: ( { e.stopPropagation() handleLegendClick( dataKey ) }} onMouseEnter={() => handleLegendEnter( dataKey ) } onMouseLeave={ handleLegendLeave } _hover={{ backgroundColor: FOUNDATION_THEME .colors .gray[200], color: legendTokens.item .color.hover || legendTokens.item .color.default, }} _focus={{ backgroundColor: FOUNDATION_THEME .colors .gray[200], color: legendTokens.item .color.hover || legendTokens.item .color.default, }} onFocus={() => handleLegendEnter( dataKey ) } onBlur={handleLegendLeave} data-element="chart-legend-text" data-id={dataKey} style={{ padding: `${FOUNDATION_THEME.unit[6]} ${FOUNDATION_THEME.unit[12]}`, fontSize: legendTokens.item .fontSize, color: String( legendTokens.item .color .default || 'inherit' ), cursor: 'pointer', opacity: itemOpacity, background: 'none', border: 'none', width: '100%', textAlign: 'left', fontFamily: 'inherit', display: 'flex', alignItems: 'center', gap: typeof legendTokens .item.gap === 'string' ? legendTokens .item.gap : `${legendTokens.item.gap}px`, transition: 'background-color 0.2s ease, color 0.2s ease, opacity 0.2s ease', }} _focusVisible={{ outline: '3px solid #BEDBFF', border: '1px solid #0561E2', cursor: 'pointer', outlineOffset: '2px', }} > {legend.total !== undefined ? ( <> {dataKey} | {legend.total} ) : ( dataKey )} ), onClick: () => handleLegendClick(dataKey), } } ), }, ]} /> )} {selectedKeys && selectedKeys.length > 0 && selectedKeys.length !== displayLegends.length && ( setSelectedKeys([])} style={{ fontSize: legendTokens.item.fontSize, display: 'flex', alignItems: 'center', justifyContent: 'center', color: FOUNDATION_THEME.colors.primary[600], borderRadius: FOUNDATION_THEME.border.radius[4], height: FOUNDATION_THEME.unit[20], width: FOUNDATION_THEME.unit[20], flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, }} _hover={{ backgroundColor: FOUNDATION_THEME.colors.primary[50], color: FOUNDATION_THEME.colors.primary[700], }} _focusVisible={{ outline: '3px solid #BEDBFF', border: '1px solid #0561E2', cursor: 'pointer', outlineOffset: '2px', }} > )} ) } ChartLegendsComponent.displayName = 'ChartLegends' export default React.memo(ChartLegendsComponent)