Skip to content

Commit 15d9a9f

Browse files
Changed TooltipComponent into a Class Component
1 parent 1d50063 commit 15d9a9f

File tree

1 file changed

+184
-159
lines changed

1 file changed

+184
-159
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import * as React from 'react';
22
import { createPortal } from 'react-dom';
33
import { debounce } from 'lodash';
4-
const { useState, useEffect, useRef, useMemo } = React;
54

65
interface TooltipProps {
76
content?: React.ReactNode;
@@ -13,182 +12,208 @@ interface TooltipState {
1312
content: React.ReactNode;
1413
visible: boolean;
1514
zIndex: number;
15+
left: number;
16+
top: number;
1617
}
1718

18-
export const TooltipComponent: React.FC<TooltipProps> = ({ content = '⏳', visible = false, fadeTransition = 500 }) => {
19-
// eslint-disable-next-line no-null/no-null
20-
const tooltipRef = useRef<HTMLDivElement>(null);
21-
const lastMousePosition = useRef({ x: 0, y: 0 });
22-
const [state, setState] = useState<TooltipState>({
23-
content,
24-
visible,
25-
zIndex: visible ? 99999 : -99999
26-
});
27-
const isMouseOverRef = useRef(false);
28-
29-
// Calculate position based on current mouse position and tooltip dimensions
30-
const calculateAndSetPosition = () => {
31-
if (!tooltipRef.current) {
32-
return;
33-
}
19+
export class TooltipComponent extends React.Component<TooltipProps, TooltipState> {
20+
public static defaultProps = {
21+
content: '⏳',
22+
visible: false,
23+
fadeTransition: 500
24+
};
3425

35-
const viewportWidth = window.innerWidth;
36-
const viewportHeight = window.innerHeight;
37-
const tooltipRect = tooltipRef.current.getBoundingClientRect();
26+
private tooltipRef: React.RefObject<HTMLDivElement>;
27+
private lastMousePosition: { x: number; y: number };
28+
private isMouseOver: boolean;
29+
private debouncedUpdate: ReturnType<typeof debounce>;
30+
31+
constructor(props: TooltipProps) {
32+
super(props);
33+
34+
this.tooltipRef = React.createRef();
35+
this.lastMousePosition = { x: 0, y: 0 };
36+
this.isMouseOver = false;
37+
38+
this.state = {
39+
content: props.content,
40+
visible: props.visible || false,
41+
zIndex: (props.visible || false) ? 99999 : -99999,
42+
left: 0,
43+
top: 0
44+
};
3845

39-
let x = lastMousePosition.current.x + 10;
40-
let y = lastMousePosition.current.y + 10;
46+
this.debouncedUpdate = debounce(this.updateState, 500);
47+
}
4148

42-
if (x + tooltipRect.width > viewportWidth) {
43-
x = lastMousePosition.current.x - tooltipRect.width - 10;
44-
}
49+
public componentDidMount(): void {
50+
// Track mouse position and detect when mouse leaves viewport
51+
document.addEventListener('mousemove', this.handleMouseMove);
52+
document.addEventListener('mouseleave', this.handleMouseLeave);
4553

46-
if (y + tooltipRect.height > viewportHeight) {
47-
y = lastMousePosition.current.y - tooltipRect.height - 10;
48-
}
54+
// Hide tooltip when scrolling (use capture to catch all scroll events)
55+
window.addEventListener('scroll', this.handleScroll, { capture: true });
56+
document.addEventListener('scroll', this.handleScroll, { capture: true });
4957

50-
tooltipRef.current.style.left = `${x}px`;
51-
tooltipRef.current.style.top = `${y}px`;
52-
};
53-
54-
const debouncedUpdate = useMemo(
55-
() =>
56-
debounce((newState: Partial<TooltipState>) => {
57-
setState(prevState => {
58-
const updatedState = { ...prevState, ...newState };
59-
60-
const weAreHidingTooltip = prevState.visible === true && newState.visible === false;
61-
const weAreHoveringOverTooltip = isMouseOverRef.current;
62-
63-
if (weAreHoveringOverTooltip) {
64-
debouncedUpdate.cancel();
65-
return prevState;
66-
} else if (weAreHidingTooltip) {
67-
// Don't update the content
68-
updatedState.content = prevState.content;
69-
// Keep z-index high during fade out
70-
updatedState.zIndex = prevState.zIndex;
71-
return updatedState;
72-
} else {
73-
calculateAndSetPosition();
74-
// Update z-index immediately when showing
75-
if (newState.visible) {
76-
updatedState.zIndex = 99999;
77-
}
78-
return updatedState;
79-
}
80-
});
81-
}, 500),
82-
[]
83-
);
84-
85-
// Track mouse position and detect when mouse leaves viewport
86-
useEffect(() => {
87-
const handleMouseMove = (e: MouseEvent) => {
88-
lastMousePosition.current = { x: e.clientX, y: e.clientY };
89-
};
58+
// Handle transition end for z-index updates
59+
if (this.tooltipRef.current) {
60+
this.tooltipRef.current.addEventListener('transitionend', this.handleTransitionEnd);
61+
}
62+
}
63+
64+
public componentDidUpdate(prevProps: TooltipProps): void {
65+
// Handle content and visibility changes
66+
if (
67+
!this.isMouseOver &&
68+
(prevProps.content !== this.props.content || prevProps.visible !== this.props.visible)
69+
) {
70+
this.debouncedUpdate({
71+
content: this.props.content,
72+
visible: this.props.visible
73+
});
74+
}
75+
}
9076

91-
const handleMouseLeave = (e: MouseEvent) => {
92-
// Check if the mouse has actually left the viewport
93-
if (e.clientY <= 0 || e.clientY >= window.innerHeight || e.clientX <= 0 || e.clientX >= window.innerWidth) {
94-
setState(prev => ({ ...prev, visible: false }));
95-
}
96-
};
77+
public componentWillUnmount(): void {
78+
// Cleanup event listeners
79+
document.removeEventListener('mousemove', this.handleMouseMove);
80+
document.removeEventListener('mouseleave', this.handleMouseLeave);
81+
window.removeEventListener('scroll', this.handleScroll, { capture: true });
82+
document.removeEventListener('scroll', this.handleScroll, { capture: true });
9783

98-
document.addEventListener('mousemove', handleMouseMove);
99-
document.addEventListener('mouseleave', handleMouseLeave);
84+
if (this.tooltipRef.current) {
85+
this.tooltipRef.current.removeEventListener('transitionend', this.handleTransitionEnd);
86+
}
10087

101-
return () => {
102-
document.removeEventListener('mousemove', handleMouseMove);
103-
document.removeEventListener('mouseleave', handleMouseLeave);
104-
};
105-
}, [debouncedUpdate]);
106-
107-
// Hide tooltip when scrolling
108-
useEffect(() => {
109-
const handleScroll = () => {
110-
setState(prev => ({
111-
...prev,
112-
visible: false
113-
}));
88+
// Cancel debounced function
89+
this.debouncedUpdate.cancel();
90+
}
91+
92+
public render(): React.ReactPortal {
93+
const tooltipStyle: React.CSSProperties = {
94+
position: 'fixed',
95+
pointerEvents: 'auto',
96+
opacity: this.state.visible ? 1 : 0,
97+
transition: `opacity ${this.props.fadeTransition}ms ease-in-out`,
98+
zIndex: this.state.zIndex,
99+
backgroundColor: '#337AB7',
100+
fontSize: '13px',
101+
color: '#fff',
102+
padding: '9px 11px',
103+
border: '1px solid transparent',
104+
borderRadius: '3px',
105+
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
106+
left: `${this.state.left}px`,
107+
top: `${this.state.top}px`
114108
};
115109

116-
// Use capture to catch all scroll events before they might be stopped
117-
const options = { capture: true };
110+
return createPortal(
111+
<div
112+
ref={this.tooltipRef}
113+
className="trace-compass-tooltip"
114+
style={tooltipStyle}
115+
onMouseEnter={this.handleTooltipMouseEnter}
116+
onMouseLeave={this.handleTooltipMouseLeave}
117+
>
118+
{this.state.content}
119+
</div>,
120+
document.body
121+
);
122+
}
123+
124+
private calculateAndSetPosition = (): void => {
125+
if (!this.tooltipRef.current) {
126+
return;
127+
}
118128

119-
window.addEventListener('scroll', handleScroll, options);
120-
document.addEventListener('scroll', handleScroll, options);
129+
const viewportWidth = window.innerWidth;
130+
const viewportHeight = window.innerHeight;
131+
const tooltipRect = this.tooltipRef.current.getBoundingClientRect();
121132

122-
return () => {
123-
window.removeEventListener('scroll', handleScroll, options);
124-
document.removeEventListener('scroll', handleScroll, options);
125-
};
126-
}, []);
133+
let left = this.lastMousePosition.x + 10;
134+
let top = this.lastMousePosition.y + 10;
127135

128-
// Handle content and visibility changes
129-
useEffect(() => {
130-
if (!isMouseOverRef.current) {
131-
debouncedUpdate({ content, visible });
136+
if (left + tooltipRect.width > viewportWidth) {
137+
left = this.lastMousePosition.x - tooltipRect.width - 10;
132138
}
133-
}, [content, visible, debouncedUpdate]);
134-
135-
// Cleanup debounced function
136-
useEffect(
137-
() => () => {
138-
debouncedUpdate.cancel();
139-
},
140-
[debouncedUpdate]
141-
);
142-
143-
// Handle opacity transition end
144-
useEffect(() => {
145-
const tooltip = tooltipRef.current;
146-
if (!tooltip) {
147-
return;
139+
140+
if (top + tooltipRect.height > viewportHeight) {
141+
top = this.lastMousePosition.y - tooltipRect.height - 10;
148142
}
149143

150-
const handleTransitionEnd = (e: TransitionEvent) => {
151-
if (e.propertyName === 'opacity' && !state.visible) {
152-
setState(prev => ({ ...prev, zIndex: -99999 }));
144+
this.setState({ left, top });
145+
}
146+
147+
/**
148+
* Updates the tooltip state with special handling for different scenarios.
149+
*
150+
* This method is debounced to prevent rapid state changes and handles several edge cases:
151+
* 1. If mouse is over the tooltip, cancels the update to prevent hiding while user is interacting
152+
* 2. When hiding the tooltip, maintains content and z-index during fade-out animation
153+
* 3. When showing the tooltip, updates position and immediately sets z-index to visible
154+
*
155+
* @param newState - Partial state update to be applied
156+
*/
157+
private updateState = (newState: Partial<TooltipState>): void => {
158+
this.setState((prevState) => {
159+
const updatedState = { ...prevState, ...newState };
160+
161+
const weAreHidingTooltip = prevState.visible === true && newState.visible === false;
162+
163+
if (this.isMouseOver) {
164+
this.debouncedUpdate.cancel();
165+
return prevState;
166+
} else if (weAreHidingTooltip) {
167+
// Don't update the content
168+
updatedState.content = prevState.content;
169+
// Keep z-index high during fade out
170+
updatedState.zIndex = prevState.zIndex;
171+
return updatedState;
172+
} else {
173+
this.calculateAndSetPosition();
174+
// Update z-index immediately when showing
175+
if (newState.visible) {
176+
updatedState.zIndex = 99999;
177+
}
178+
return updatedState;
153179
}
154-
};
155-
156-
tooltip.addEventListener('transitionend', handleTransitionEnd);
157-
return () => {
158-
tooltip.removeEventListener('transitionend', handleTransitionEnd);
159-
};
160-
}, [state.visible]);
161-
162-
const tooltipStyle: React.CSSProperties = {
163-
position: 'fixed',
164-
pointerEvents: 'auto',
165-
opacity: state.visible ? 1 : 0,
166-
transition: `opacity ${fadeTransition}ms ease-in-out`,
167-
zIndex: state.zIndex,
168-
backgroundColor: '#337AB7',
169-
fontSize: '13px',
170-
color: '#fff',
171-
padding: '9px 11px',
172-
border: '1px solid transparent',
173-
borderRadius: '3px',
174-
boxShadow: '0 2px 4px rgba(0,0,0,0.2)'
180+
});
175181
};
176182

177-
return createPortal(
178-
<div
179-
ref={tooltipRef}
180-
className="trace-compass-tooltip"
181-
style={tooltipStyle}
182-
onMouseEnter={() => {
183-
isMouseOverRef.current = true;
184-
}}
185-
onMouseLeave={() => {
186-
isMouseOverRef.current = false;
187-
setState(prev => ({ ...prev, visible: false }));
188-
}}
189-
>
190-
{state.content}
191-
</div>,
192-
document.body
193-
);
194-
};
183+
private handleMouseMove = (e: MouseEvent): void => {
184+
this.lastMousePosition = { x: e.clientX, y: e.clientY };
185+
}
186+
187+
private handleMouseLeave = (e: MouseEvent): void => {
188+
// Check if the mouse has actually left the viewport
189+
if (
190+
e.clientY <= 0 ||
191+
e.clientY >= window.innerHeight ||
192+
e.clientX <= 0 ||
193+
e.clientX >= window.innerWidth
194+
) {
195+
this.setState({ visible: false });
196+
}
197+
}
198+
199+
private handleScroll = (): void => {
200+
this.setState({
201+
visible: false
202+
});
203+
}
204+
205+
private handleTransitionEnd = (e: TransitionEvent): void => {
206+
if (e.propertyName === 'opacity' && !this.state.visible) {
207+
this.setState({ zIndex: -99999 });
208+
}
209+
}
210+
211+
private handleTooltipMouseEnter = (): void => {
212+
this.isMouseOver = true;
213+
}
214+
215+
private handleTooltipMouseLeave = (): void => {
216+
this.isMouseOver = false;
217+
this.setState({ visible: false });
218+
}
219+
}

0 commit comments

Comments
 (0)