Skip to content

Commit ad68cfc

Browse files
committed
refactor: simplify BasicTooltip and add auto-flip for viewport overflow
1 parent 8419b34 commit ad68cfc

2 files changed

Lines changed: 55 additions & 189 deletions

File tree

airflow-core/src/airflow/ui/src/components/BasicTooltip.tsx

Lines changed: 54 additions & 188 deletions
Original file line numberDiff line numberDiff line change
@@ -16,177 +16,38 @@
1616
* specific language governing permissions and limitations
1717
* under the License.
1818
*/
19-
import { Portal } from "@chakra-ui/react";
20-
import type { CSSProperties, ReactElement, ReactNode } from "react";
21-
import { cloneElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
22-
23-
export type TooltipPlacement =
24-
| "bottom-end"
25-
| "bottom-start"
26-
| "bottom"
27-
| "left"
28-
| "right"
29-
| "top-end"
30-
| "top-start"
31-
| "top";
19+
import { Box, Portal } from "@chakra-ui/react";
20+
import type { ReactElement, ReactNode } from "react";
21+
import { cloneElement, useCallback, useEffect, useRef, useState } from "react";
3222

3323
type Props = {
3424
readonly children: ReactElement;
3525
readonly content: ReactNode;
36-
readonly placement?: TooltipPlacement;
3726
};
3827

39-
const calculatePosition = (
40-
rect: DOMRect,
41-
placement: TooltipPlacement,
42-
offset: number,
43-
): { left: string; top: string; transform: string } => {
44-
const { bottom, height, left, right, top, width } = rect;
45-
const { scrollX, scrollY } = globalThis;
46-
47-
switch (placement) {
48-
case "bottom":
49-
return {
50-
left: `${left + scrollX + width / 2}px`,
51-
top: `${bottom + scrollY + offset}px`,
52-
transform: "translateX(-50%)",
53-
};
54-
55-
case "bottom-end":
56-
return {
57-
left: `${right + scrollX}px`,
58-
top: `${bottom + scrollY + offset}px`,
59-
transform: "translateX(-100%)",
60-
};
61-
62-
case "bottom-start":
63-
return {
64-
left: `${left + scrollX}px`,
65-
top: `${bottom + scrollY + offset}px`,
66-
transform: "none",
67-
};
68-
69-
case "left":
70-
return {
71-
left: `${left + scrollX - offset}px`,
72-
top: `${top + scrollY + height / 2}px`,
73-
transform: "translate(-100%, -50%)",
74-
};
75-
76-
case "right":
77-
return {
78-
left: `${right + scrollX + offset}px`,
79-
top: `${top + scrollY + height / 2}px`,
80-
transform: "translateY(-50%)",
81-
};
82-
83-
case "top":
84-
return {
85-
left: `${left + scrollX + width / 2}px`,
86-
top: `${top + scrollY - offset}px`,
87-
transform: "translate(-50%, -100%)",
88-
};
89-
90-
case "top-end":
91-
return {
92-
left: `${right + scrollX}px`,
93-
top: `${top + scrollY - offset}px`,
94-
transform: "translate(-100%, -100%)",
95-
};
96-
97-
case "top-start":
98-
return {
99-
left: `${left + scrollX}px`,
100-
top: `${top + scrollY - offset}px`,
101-
transform: "translateY(-100%)",
102-
};
103-
104-
default:
105-
return {
106-
left: `${left + scrollX + width / 2}px`,
107-
top: `${top + scrollY - offset}px`,
108-
transform: "translate(-50%, -100%)",
109-
};
110-
}
111-
};
112-
113-
const getArrowStyle = (placement: TooltipPlacement): CSSProperties => {
114-
const baseStyle: CSSProperties = {
115-
content: '""',
116-
height: 0,
117-
position: "absolute",
118-
width: 0,
119-
};
120-
121-
switch (placement) {
122-
case "bottom":
123-
case "bottom-end":
124-
case "bottom-start":
125-
return {
126-
...baseStyle,
127-
borderBottom: "4px solid var(--chakra-colors-bg-inverted)",
128-
borderLeft: "4px solid transparent",
129-
borderRight: "4px solid transparent",
130-
left: placement === "bottom" ? "50%" : placement === "bottom-start" ? "12px" : undefined,
131-
right: placement === "bottom-end" ? "12px" : undefined,
132-
top: "-4px",
133-
transform: placement === "bottom" ? "translateX(-50%)" : undefined,
134-
};
135-
136-
case "left":
137-
return {
138-
...baseStyle,
139-
borderBottom: "4px solid transparent",
140-
borderLeft: "4px solid var(--chakra-colors-bg-inverted)",
141-
borderTop: "4px solid transparent",
142-
right: "-4px",
143-
top: "50%",
144-
transform: "translateY(-50%)",
145-
};
146-
147-
case "right":
148-
return {
149-
...baseStyle,
150-
borderBottom: "4px solid transparent",
151-
borderRight: "4px solid var(--chakra-colors-bg-inverted)",
152-
borderTop: "4px solid transparent",
153-
left: "-4px",
154-
top: "50%",
155-
transform: "translateY(-50%)",
156-
};
28+
const offset = 8;
29+
const zIndex = 1500;
30+
// Estimated tooltip height for viewport boundary detection
31+
const estimatedTooltipHeight = 100;
15732

158-
case "top":
159-
case "top-end":
160-
case "top-start":
161-
return {
162-
...baseStyle,
163-
borderLeft: "4px solid transparent",
164-
borderRight: "4px solid transparent",
165-
borderTop: "4px solid var(--chakra-colors-bg-inverted)",
166-
bottom: "-4px",
167-
left: placement === "top" ? "50%" : placement === "top-start" ? "12px" : undefined,
168-
right: placement === "top-end" ? "12px" : undefined,
169-
transform: placement === "top" ? "translateX(-50%)" : undefined,
170-
};
171-
172-
default:
173-
return baseStyle;
174-
}
175-
};
176-
177-
export const BasicTooltip = ({ children, content, placement = "bottom" }: Props): ReactElement => {
33+
export const BasicTooltip = ({ children, content }: Props): ReactElement => {
17834
const triggerRef = useRef<HTMLElement>(null);
17935
const [isOpen, setIsOpen] = useState(false);
36+
const [showOnTop, setShowOnTop] = useState(false);
18037
const timeoutRef = useRef<NodeJS.Timeout>();
18138

182-
const offset = 8;
183-
const zIndex = 1500;
184-
18539
const handleMouseEnter = useCallback(() => {
18640
if (timeoutRef.current) {
18741
clearTimeout(timeoutRef.current);
18842
}
18943
timeoutRef.current = setTimeout(() => {
44+
// Check if tooltip would overflow viewport bottom
45+
if (triggerRef.current) {
46+
const triggerRect = triggerRef.current.getBoundingClientRect();
47+
const wouldOverflow = triggerRect.bottom + offset + estimatedTooltipHeight > globalThis.innerHeight;
48+
49+
setShowOnTop(wouldOverflow);
50+
}
19051
setIsOpen(true);
19152
}, 500);
19253
}, []);
@@ -209,49 +70,54 @@ export const BasicTooltip = ({ children, content, placement = "bottom" }: Props)
20970
[],
21071
);
21172

212-
const tooltipStyle = useMemo(() => {
213-
if (!isOpen || !triggerRef.current) {
214-
return { display: "none" };
215-
}
216-
217-
const rect = triggerRef.current.getBoundingClientRect();
218-
const position = calculatePosition(rect, placement, offset);
219-
220-
return {
221-
...position,
222-
backgroundColor: "var(--chakra-colors-bg-inverted)",
223-
borderRadius: "4px",
224-
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
225-
color: "var(--chakra-colors-fg-inverted)",
226-
fontSize: "14px",
227-
padding: "8px 12px",
228-
pointerEvents: "none" as const,
229-
position: "absolute" as const,
230-
whiteSpace: "nowrap" as const,
231-
zIndex,
232-
};
233-
}, [isOpen, placement, offset, zIndex]);
234-
235-
const arrowStyle = useMemo(() => getArrowStyle(placement), [placement]);
236-
23773
// Clone children and attach event handlers + ref
23874
const trigger = cloneElement(children, {
23975
onMouseEnter: handleMouseEnter,
24076
onMouseLeave: handleMouseLeave,
24177
ref: triggerRef,
24278
});
24379

80+
if (!isOpen || !triggerRef.current) {
81+
return trigger;
82+
}
83+
84+
const rect = triggerRef.current.getBoundingClientRect();
85+
const { scrollX, scrollY } = globalThis;
86+
24487
return (
24588
<>
24689
{trigger}
247-
{Boolean(isOpen) && (
248-
<Portal>
249-
<div style={tooltipStyle}>
250-
<div style={arrowStyle} />
251-
{content ?? undefined}
252-
</div>
253-
</Portal>
254-
)}
90+
<Portal>
91+
<Box
92+
bg="bg.inverted"
93+
borderRadius="4px"
94+
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
95+
color="fg.inverted"
96+
fontSize="14px"
97+
left={`${rect.left + scrollX + rect.width / 2}px`}
98+
padding="8px 12px"
99+
pointerEvents="none"
100+
position="absolute"
101+
top={showOnTop ? `${rect.top + scrollY - offset}px` : `${rect.bottom + scrollY + offset}px`}
102+
transform={showOnTop ? "translate(-50%, -100%)" : "translateX(-50%)"}
103+
whiteSpace="nowrap"
104+
zIndex={zIndex}
105+
>
106+
<Box
107+
borderLeft="4px solid transparent"
108+
borderRight="4px solid transparent"
109+
height={0}
110+
left="50%"
111+
position="absolute"
112+
transform="translateX(-50%)"
113+
width={0}
114+
{...(showOnTop
115+
? { borderTop: "4px solid var(--chakra-colors-bg-inverted)", bottom: "-4px" }
116+
: { borderBottom: "4px solid var(--chakra-colors-bg-inverted)", top: "-4px" })}
117+
/>
118+
{content}
119+
</Box>
120+
</Portal>
255121
</>
256122
);
257123
};

airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export const CalendarCell = ({
9797
}
9898

9999
return (
100-
<BasicTooltip content={<CalendarTooltip cellData={cellData} viewMode={viewMode} />} placement="bottom">
100+
<BasicTooltip content={<CalendarTooltip cellData={cellData} viewMode={viewMode} />}>
101101
{cellBox}
102102
</BasicTooltip>
103103
);

0 commit comments

Comments
 (0)