[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:
+217
-31
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+17
@@ -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} />;
|
||||
};
|
||||
+3
-10
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user