Skip to content

Commit 5612974

Browse files
Anchored tooltip (#15568)
Issue - - tooltip gets too long when groupby has too much data points - we need max height on tooltips - we need to be able to scroll inside the tooltip - not possible if the tooltip is following the cursor (which library enforces) - its not easy to customize Nivo's own tooltip to make it work how we want it what we want - - let the tooltip anchor to the bar in such a way -- that the top of the bar aligns with the vertical middle of the tooltip - add max height to the tooltip what I did - - use floating portal from floating-ui/react - get the hover datum (ie hovered bars) dimensions on mouse enter to render the tooltip - anchor the tooltip at the calculated position - floating-ui handles the basic things like flipping/offset/shift - clear the states as required --------- Co-authored-by: Lucas Bordeau <[email protected]>
1 parent 652c867 commit 5612974

File tree

44 files changed

+1117
-447
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1117
-447
lines changed

packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidget.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export const GraphWidget = ({
118118
yScale={data.yScale}
119119
curve={data.curve}
120120
stackedArea={data.stackedArea}
121-
enableSlices={data.enableSlices}
121+
enableSlices={'x'}
122122
/>
123123
</Suspense>
124124
);
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import {
2+
GraphWidgetTooltip,
3+
type GraphWidgetTooltipItem,
4+
} from '@/page-layout/widgets/graph/components/GraphWidgetTooltip';
5+
import { useGraphWidgetTooltipFloating } from '@/page-layout/widgets/graph/hooks/useGraphWidgetTooltipFloating';
6+
import { useTheme } from '@emotion/react';
7+
import { FloatingPortal, type VirtualElement } from '@floating-ui/react';
8+
import { AnimatePresence, motion } from 'framer-motion';
9+
import { isDefined } from 'twenty-shared/utils';
10+
11+
type GraphWidgetFloatingTooltipProps = {
12+
reference: Element | VirtualElement;
13+
boundary: Element;
14+
items: GraphWidgetTooltipItem[];
15+
indexLabel?: string;
16+
highlightedKey?: string;
17+
linkTo?: string;
18+
onMouseEnter?: () => void;
19+
onMouseLeave?: () => void;
20+
};
21+
22+
export const GraphWidgetFloatingTooltip = ({
23+
reference,
24+
boundary,
25+
items,
26+
indexLabel,
27+
highlightedKey,
28+
linkTo,
29+
onMouseEnter,
30+
onMouseLeave,
31+
}: GraphWidgetFloatingTooltipProps) => {
32+
const theme = useTheme();
33+
34+
const { refs, floatingStyles } = useGraphWidgetTooltipFloating(
35+
reference,
36+
boundary,
37+
);
38+
39+
if (!isDefined(boundary) || !(boundary instanceof HTMLElement)) {
40+
return null;
41+
}
42+
43+
return (
44+
<FloatingPortal root={boundary}>
45+
<div
46+
ref={refs.setFloating}
47+
style={{ ...floatingStyles, zIndex: theme.lastLayerZIndex }}
48+
role="tooltip"
49+
aria-live="polite"
50+
onMouseEnter={onMouseEnter}
51+
onMouseLeave={onMouseLeave}
52+
>
53+
<AnimatePresence>
54+
<motion.div
55+
initial={{
56+
opacity: 0,
57+
scale: 0.95,
58+
}}
59+
animate={{ opacity: 1, scale: 1 }}
60+
exit={{ opacity: 0, scale: 0.95 }}
61+
transition={{
62+
duration: theme.animation.duration.fast,
63+
ease: 'easeInOut',
64+
}}
65+
>
66+
<GraphWidgetTooltip
67+
items={items}
68+
indexLabel={indexLabel}
69+
highlightedKey={highlightedKey}
70+
linkTo={linkTo}
71+
/>
72+
</motion.div>
73+
</AnimatePresence>
74+
</div>
75+
</FloatingPortal>
76+
);
77+
};

packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetTooltip.tsx

Lines changed: 59 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { GRAPH_TOOLTIP_MAX_WIDTH_PX } from '@/page-layout/widgets/graph/components/constants/GraphTooltipMaxWidthPx';
2+
import { GRAPH_TOOLTIP_MIN_WIDTH_PX } from '@/page-layout/widgets/graph/components/constants/GraphTooltipMinWidthPx';
3+
import { GRAPH_TOOLTIP_SCROLL_MAX_HEIGHT_PX } from '@/page-layout/widgets/graph/components/constants/GraphTooltipScrollMaxHeightPx';
14
import { useTheme } from '@emotion/react';
25
import styled from '@emotion/styled';
36
import { t } from '@lingui/core/macro';
@@ -12,9 +15,9 @@ const StyledTooltip = styled.div`
1215
display: flex;
1316
flex-direction: column;
1417
gap: 2px;
15-
max-width: min(300px, calc(100vw - 40px));
16-
min-width: 160px;
17-
pointer-events: none;
18+
max-width: min(${GRAPH_TOOLTIP_MAX_WIDTH_PX}px, calc(100vw - 40px));
19+
min-width: ${GRAPH_TOOLTIP_MIN_WIDTH_PX}px;
20+
pointer-events: auto;
1821
`;
1922

2023
const StyledTooltipContent = styled.div`
@@ -35,10 +38,12 @@ const StyledTooltipRowContainer = styled.div`
3538
display: flex;
3639
flex-direction: column;
3740
gap: ${({ theme }) => theme.spacing(2)};
41+
max-height: ${GRAPH_TOOLTIP_SCROLL_MAX_HEIGHT_PX}px;
42+
overflow-y: auto;
3843
`;
3944

40-
const StyledDot = styled.div<{ $color: string }>`
41-
background: ${({ $color }) => $color};
45+
const StyledDot = styled.div<{ color: string }>`
46+
background: ${({ color }) => color};
4247
border-radius: 50%;
4348
height: 6px;
4449
width: 6px;
@@ -48,7 +53,7 @@ const StyledDot = styled.div<{ $color: string }>`
4853
const StyledTooltipLink = styled.div`
4954
align-items: center;
5055
color: ${({ theme }) => theme.font.color.light};
51-
cursor: default;
56+
cursor: pointer;
5257
display: flex;
5358
justify-content: space-between;
5459
height: ${({ theme }) => theme.spacing(6)};
@@ -85,32 +90,39 @@ const StyledTooltipRowRightContent = styled.div`
8590
width: 100%;
8691
`;
8792

88-
const StyledTooltipLabel = styled.span`
89-
color: ${({ theme }) => theme.font.color.tertiary};
93+
const StyledTooltipLabel = styled.span<{ isHighlighted?: boolean }>`
94+
color: ${({ theme, isHighlighted }) =>
95+
isHighlighted ? theme.font.color.secondary : theme.font.color.tertiary};
9096
flex: 1;
9197
min-width: 0;
9298
overflow: hidden;
9399
text-overflow: ellipsis;
94100
white-space: nowrap;
101+
font-weight: ${({ theme, isHighlighted }) =>
102+
isHighlighted ? theme.font.weight.medium : theme.font.weight.regular};
95103
`;
96104

97-
const StyledTooltipValue = styled.span`
105+
const StyledTooltipValue = styled.span<{ isHighlighted?: boolean }>`
106+
color: ${({ theme, isHighlighted }) =>
107+
isHighlighted ? theme.font.color.tertiary : theme.font.color.extraLight};
98108
flex-shrink: 0;
99-
font-weight: ${({ theme }) => theme.font.weight.medium};
109+
font-weight: ${({ theme, isHighlighted }) =>
110+
isHighlighted ? theme.font.weight.semiBold : theme.font.weight.medium};
100111
white-space: nowrap;
101112
`;
102113

103114
const StyledHorizontalSectionPadding = styled.div<{
104-
$addTop?: boolean;
105-
$addBottom?: boolean;
115+
addTop?: boolean;
116+
addBottom?: boolean;
106117
}>`
107118
padding-inline: ${({ theme }) => theme.spacing(1)};
108-
margin-top: ${({ $addTop, theme }) => ($addTop ? theme.spacing(1) : 0)};
109-
margin-bottom: ${({ $addBottom, theme }) =>
110-
$addBottom ? theme.spacing(1) : 0};
119+
margin-top: ${({ addTop, theme }) => (addTop ? theme.spacing(1) : 0)};
120+
margin-bottom: ${({ addBottom, theme }) =>
121+
addBottom ? theme.spacing(1) : 0};
111122
`;
112123

113124
export type GraphWidgetTooltipItem = {
125+
key: string;
114126
label: string;
115127
formattedValue: string;
116128
value: number;
@@ -119,46 +131,63 @@ export type GraphWidgetTooltipItem = {
119131

120132
type GraphWidgetTooltipProps = {
121133
items: GraphWidgetTooltipItem[];
122-
showClickHint?: boolean;
123134
indexLabel?: string;
135+
highlightedKey?: string;
136+
linkTo?: string;
124137
};
125138

126139
export const GraphWidgetTooltip = ({
127140
items,
128-
showClickHint = false,
129141
indexLabel,
142+
highlightedKey,
143+
linkTo,
130144
}: GraphWidgetTooltipProps) => {
131145
const theme = useTheme();
132146

133147
const filteredItems = items.filter(
134148
(item) => item.value !== 0 && isNonEmptyString(item.formattedValue),
135149
);
136150

151+
const shouldHighlight = filteredItems.length > 1;
152+
const hasLink = isNonEmptyString(linkTo);
153+
137154
return (
138155
<StyledTooltip>
139-
<StyledHorizontalSectionPadding $addTop $addBottom={!showClickHint}>
156+
<StyledHorizontalSectionPadding addTop addBottom={!hasLink}>
140157
<StyledTooltipContent>
141158
{indexLabel && (
142159
<StyledTooltipHeader>{indexLabel}</StyledTooltipHeader>
143160
)}
144161
<StyledTooltipRowContainer>
145-
{filteredItems.map((item, index) => (
146-
<StyledTooltipRow key={index}>
147-
<StyledDot $color={item.dotColor} />
148-
<StyledTooltipRowRightContent>
149-
<StyledTooltipLabel>{item.label}</StyledTooltipLabel>
150-
<StyledTooltipValue>{item.formattedValue}</StyledTooltipValue>
151-
</StyledTooltipRowRightContent>
152-
</StyledTooltipRow>
153-
))}
162+
{filteredItems.map((item) => {
163+
const isHighlighted =
164+
shouldHighlight && highlightedKey === item.key;
165+
return (
166+
<StyledTooltipRow key={item.key}>
167+
<StyledDot color={item.dotColor} />
168+
<StyledTooltipRowRightContent>
169+
<StyledTooltipLabel isHighlighted={isHighlighted}>
170+
{item.label}
171+
</StyledTooltipLabel>
172+
<StyledTooltipValue isHighlighted={isHighlighted}>
173+
{item.formattedValue}
174+
</StyledTooltipValue>
175+
</StyledTooltipRowRightContent>
176+
</StyledTooltipRow>
177+
);
178+
})}
154179
</StyledTooltipRowContainer>
155180
</StyledTooltipContent>
156181
</StyledHorizontalSectionPadding>
157-
{showClickHint && (
182+
{hasLink && (
158183
<>
159184
<StyledTooltipSeparator />
160-
<StyledHorizontalSectionPadding $addBottom>
161-
<StyledTooltipLink>
185+
<StyledHorizontalSectionPadding addBottom>
186+
<StyledTooltipLink
187+
onClick={() => {
188+
window.location.href = String(linkTo);
189+
}}
190+
>
162191
<span>{t`Click to see data`}</span>
163192
<IconArrowUpRight size={theme.icon.size.sm} />
164193
</StyledTooltipLink>

0 commit comments

Comments
 (0)