[DASHBOARDS] Create display legend setting + animations (#16089)

## PR Description
- Create display legend setting
- Create pagination inside the legend
- Animate the legend on enter/exit
- Animate the transition between pages

## Video QA


https://github.com/user-attachments/assets/a68a4ea3-195f-4399-88f7-f83bc1a39cc4
This commit is contained in:
Raphaël Bosi
2025-11-26 18:03:23 +01:00
committed by GitHub
parent 7f441d741b
commit fc4dbb80de
34 changed files with 343 additions and 57 deletions
@@ -1,5 +1,18 @@
import { CHART_LEGEND_ITEM_THRESHOLD } from '@/page-layout/widgets/graph/constants/ChartLegendItemThreshold';
import { GraphWidgetLegendDot } from '@/page-layout/widgets/graph/components/GraphWidgetLegendDot';
import { LEGEND_ITEM_ESTIMATED_WIDTH } from '@/page-layout/widgets/graph/constants/LegendItemEstimatedWidth.constant';
import { LEGEND_LABEL_MAX_WIDTH } from '@/page-layout/widgets/graph/constants/LegendLabelMaxWidth.constant';
import { LEGEND_PAGINATION_CONTROLS_WIDTH } from '@/page-layout/widgets/graph/constants/LegendPaginationControlsWidth.constant';
import { NodeDimensionEffect } from '@/ui/utilities/dimensions/components/NodeDimensionEffect';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { AnimatePresence, motion } from 'framer-motion';
import { useRef, useState } from 'react';
import {
IconChevronLeft,
IconChevronRight,
OverflowingTextWithTooltip,
} from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
export type GraphWidgetLegendItem = {
id: string;
@@ -12,59 +25,232 @@ type GraphWidgetLegendProps = {
show?: boolean;
};
const StyledLegendContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.spacing(3)};
justify-content: center;
padding-top: ${({ theme }) => theme.spacing(3)};
type AnimationDirection = 'forward' | 'backward';
const StyledAnimationClipContainer = styled.div`
flex: 1;
min-width: 0;
overflow: hidden;
position: relative;
`;
const StyledLegendItem = styled.div`
const StyledLegendMotionWrapper = styled(motion.div)`
width: 100%;
`;
const StyledItemsWrapper = styled(motion.div)<{ centered?: boolean }>`
display: flex;
gap: ${({ theme }) => theme.spacing(3)};
flex-wrap: nowrap;
flex: 1;
min-width: 0;
justify-content: ${({ centered }) => (centered ? 'center' : 'flex-start')};
`;
const StyledLegendContainer = styled.div<{ needsPagination: boolean }>`
display: flex;
flex-wrap: nowrap;
gap: ${({ theme }) => theme.spacing(3)};
justify-content: ${({ needsPagination }) =>
needsPagination ? 'flex-start' : 'center'};
padding-top: ${({ theme }) => theme.spacing(3)};
overflow: hidden;
width: 100%;
align-items: center;
`;
const StyledLegendItem = styled.div<{ canShrink?: boolean }>`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
flex-shrink: ${({ canShrink }) => (canShrink ? 1 : 0)};
min-width: 0;
`;
const StyledLegendLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
const StyledLegendLabel = styled.div<{ fixedWidth?: boolean }>`
color: ${({ theme }) => theme.font.color.secondary};
${({ fixedWidth }) => fixedWidth && `width: ${LEGEND_LABEL_MAX_WIDTH}px;`}
overflow: hidden;
`;
const StyledDot = styled.div<{ color: string }>`
background: ${({ color }) => color};
border-radius: 50%;
height: 6px;
width: 6px;
const StyledPaginationContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
flex-shrink: 0;
gap: ${({ theme }) => theme.spacing(0.5)};
`;
const StyledPaginationIndicator = styled.span`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
`;
const legendEnterExitVariants = {
hidden: {
y: 10,
opacity: 0,
},
visible: {
y: 0,
opacity: 1,
},
exit: {
y: 10,
opacity: 0,
},
};
export const GraphWidgetLegend = ({
items,
show = true,
}: GraphWidgetLegendProps) => {
const areThereTooManyItems = items.length > CHART_LEGEND_ITEM_THRESHOLD;
const containerRef = useRef<HTMLDivElement>(null);
const isItASingleItem = items.length === 1;
const [currentPage, setCurrentPage] = useState(0);
const thereAreNoItems = items.length === 0;
const [containerWidth, setContainerWidth] = useState(0);
const shouldShowLegend =
show && !areThereTooManyItems && !isItASingleItem && !thereAreNoItems;
const [animationDirection, setAnimationDirection] =
useState<AnimationDirection>('forward');
if (!shouldShowLegend) {
return null;
}
const theme = useTheme();
const shouldShowLegend = show && items.length > 1;
const availableWidth = containerWidth - LEGEND_PAGINATION_CONTROLS_WIDTH;
const itemsPerPage = Math.max(
1,
Math.floor(availableWidth / LEGEND_ITEM_ESTIMATED_WIDTH),
);
const needsPagination = items.length > itemsPerPage;
const totalPages = Math.ceil(items.length / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, Math.max(0, totalPages - 1));
const startIndex = safeCurrentPage * itemsPerPage;
const visibleItems = needsPagination
? items.slice(startIndex, startIndex + itemsPerPage)
: items;
const handlePreviousPage = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
setAnimationDirection('backward');
setCurrentPage((prevPage) => Math.max(0, prevPage - 1));
};
const handleNextPage = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
setAnimationDirection('forward');
setCurrentPage((prevPage) => Math.min(totalPages - 1, prevPage + 1));
};
const slideOffset = availableWidth;
const variants = {
enter: (direction: AnimationDirection) => ({
x: direction === 'forward' ? slideOffset : -slideOffset,
}),
center: {
x: 0,
},
exit: (direction: AnimationDirection) => ({
x: direction === 'forward' ? -slideOffset : slideOffset,
}),
};
return (
<StyledLegendContainer>
{items.map((item) => (
<StyledLegendItem key={item.id}>
<StyledDot color={item.color} />
<StyledLegendLabel>{item.label}</StyledLegendLabel>
</StyledLegendItem>
))}
</StyledLegendContainer>
<AnimatePresence mode="popLayout" initial={false}>
{shouldShowLegend && (
<StyledLegendMotionWrapper
variants={legendEnterExitVariants}
initial="hidden"
animate="visible"
exit="exit"
transition={{
duration: theme.animation.duration.normal,
ease: 'easeInOut',
}}
>
<StyledLegendContainer
ref={containerRef}
needsPagination={needsPagination}
>
<NodeDimensionEffect
elementRef={containerRef}
onDimensionChange={({ width }) => {
const newItemsPerPage = Math.max(
1,
Math.floor(
(width - LEGEND_PAGINATION_CONTROLS_WIDTH) /
LEGEND_ITEM_ESTIMATED_WIDTH,
),
);
if (newItemsPerPage !== itemsPerPage) {
setCurrentPage(0);
}
setContainerWidth(width);
}}
/>
{needsPagination && (
<StyledPaginationContainer>
<LightIconButton
onClick={handlePreviousPage}
disabled={safeCurrentPage === 0}
Icon={IconChevronLeft}
accent="tertiary"
/>
<StyledPaginationIndicator>
{safeCurrentPage + 1}/{totalPages}
</StyledPaginationIndicator>
<LightIconButton
onClick={handleNextPage}
disabled={safeCurrentPage === totalPages - 1}
Icon={IconChevronRight}
/>
</StyledPaginationContainer>
)}
<StyledAnimationClipContainer>
<AnimatePresence
mode="popLayout"
custom={animationDirection}
initial={false}
>
<StyledItemsWrapper
key={safeCurrentPage}
custom={animationDirection}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
duration: theme.animation.duration.normal,
ease: 'easeInOut',
}}
centered={!needsPagination}
>
{visibleItems.map((item) => (
<StyledLegendItem
key={item.id}
canShrink={!needsPagination}
>
<GraphWidgetLegendDot color={item.color} />
<StyledLegendLabel fixedWidth={needsPagination}>
<OverflowingTextWithTooltip text={item.label} />
</StyledLegendLabel>
</StyledLegendItem>
))}
</StyledItemsWrapper>
</AnimatePresence>
</StyledAnimationClipContainer>
</StyledLegendContainer>
</StyledLegendMotionWrapper>
)}
</AnimatePresence>
);
};
@@ -0,0 +1,17 @@
import styled from '@emotion/styled';
type GraphWidgetLegendDotProps = {
color: string;
};
const StyledDot = styled.div<{ color: string }>`
background: ${({ color }) => color};
border-radius: 2px;
height: 8px;
width: 8px;
flex-shrink: 0;
`;
export const GraphWidgetLegendDot = ({ color }: GraphWidgetLegendDotProps) => {
return <StyledDot color={color} />;
};
@@ -1,12 +1,13 @@
import { GRAPH_TOOLTIP_MAX_WIDTH_PX } from '@/page-layout/widgets/graph/components/constants/GraphTooltipMaxWidthPx';
import { GRAPH_TOOLTIP_MIN_WIDTH_PX } from '@/page-layout/widgets/graph/components/constants/GraphTooltipMinWidthPx';
import { GRAPH_TOOLTIP_SCROLL_MAX_HEIGHT_PX } from '@/page-layout/widgets/graph/components/constants/GraphTooltipScrollMaxHeightPx';
import { GraphWidgetLegendDot } from '@/page-layout/widgets/graph/components/GraphWidgetLegendDot';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { IconArrowUpRight } from 'twenty-ui/display';
import { isDefined } from 'twenty-shared/utils';
import { IconArrowUpRight } from 'twenty-ui/display';
const StyledTooltip = styled.div`
background: ${({ theme }) => theme.background.primary};
@@ -43,14 +44,6 @@ const StyledTooltipRowContainer = styled.div`
overflow-y: auto;
`;
const StyledDot = styled.div<{ color: string }>`
background: ${({ color }) => color};
border-radius: 50%;
height: 6px;
width: 6px;
flex-shrink: 0;
`;
const StyledTooltipLink = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
@@ -168,7 +161,7 @@ export const GraphWidgetTooltip = ({
shouldHighlight && highlightedKey === item.key;
return (
<StyledTooltipRow key={item.key}>
<StyledDot color={item.dotColor} />
<GraphWidgetLegendDot color={item.dotColor} />
<StyledTooltipRowRightContent>
<StyledTooltipLabel isHighlighted={isHighlighted}>
{item.label}