Skip to content

Commit f5afb5c

Browse files
committed
🤖 feat: extend Tooltip to support left/right positioning
- Added position='left' and position='right' to Tooltip component - Includes collision detection (flips to opposite side if needed) - Arrow properly positioned for horizontal tooltips - Used for vertical threshold slider tooltip (avoids overflow clipping)
1 parent 4d31e9e commit f5afb5c

File tree

2 files changed

+116
-50
lines changed

2 files changed

+116
-50
lines changed

src/browser/components/RightSidebar/ThresholdSlider.tsx

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
AUTO_COMPACTION_THRESHOLD_MIN,
44
AUTO_COMPACTION_THRESHOLD_MAX,
55
} from "@/common/constants/ui";
6+
import { TooltipWrapper, Tooltip } from "../Tooltip";
67

78
// ----- Types -----
89

@@ -169,12 +170,37 @@ export const ThresholdSlider: React.FC<ThresholdSliderProps> = ({ config, orient
169170
? { width: 1, height: 6, background: color }
170171
: { width: 6, height: 1, background: color };
171172

173+
// Indicator content (triangles + line)
174+
const indicatorContent = (
175+
<>
176+
<Triangle direction={isHorizontal ? "down" : "right"} color={color} />
177+
<div style={lineStyle} />
178+
<Triangle direction={isHorizontal ? "up" : "left"} color={color} />
179+
</>
180+
);
181+
172182
return (
173-
<div ref={containerRef} style={containerStyle} onMouseDown={handleMouseDown} title={title}>
183+
<div ref={containerRef} style={containerStyle} onMouseDown={handleMouseDown}>
174184
<div style={indicatorStyle}>
175-
<Triangle direction={isHorizontal ? "down" : "right"} color={color} />
176-
<div style={lineStyle} />
177-
<Triangle direction={isHorizontal ? "up" : "left"} color={color} />
185+
{isHorizontal ? (
186+
// Horizontal: native title tooltip (works well positioned at cursor)
187+
<div
188+
title={title}
189+
style={{ display: "flex", flexDirection: "column", alignItems: "center" }}
190+
>
191+
{indicatorContent}
192+
</div>
193+
) : (
194+
// Vertical: portal-based Tooltip to avoid clipping by overflow:hidden containers
195+
<TooltipWrapper inline>
196+
<div style={{ display: "flex", flexDirection: "row", alignItems: "center" }}>
197+
{indicatorContent}
198+
</div>
199+
<Tooltip position="left">
200+
<div style={{ fontSize: 11 }}>{title}</div>
201+
</Tooltip>
202+
</TooltipWrapper>
203+
)}
178204
</div>
179205
</div>
180206
);

src/browser/components/Tooltip.tsx

Lines changed: 86 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export const TooltipWrapper: React.FC<TooltipWrapperProps> = ({ inline = false,
6060
interface TooltipProps {
6161
align?: "left" | "center" | "right";
6262
width?: "auto" | "wide";
63-
position?: "top" | "bottom";
63+
position?: "top" | "bottom" | "left" | "right";
6464
children: React.ReactNode;
6565
className?: string;
6666
interactive?: boolean;
@@ -109,52 +109,99 @@ export const Tooltip: React.FC<TooltipProps> = ({
109109
let left: number;
110110
let finalPosition = position;
111111
const gap = 8; // Gap between trigger and tooltip
112+
const isHorizontalPosition = position === "left" || position === "right";
112113

113-
// Vertical positioning with collision detection
114-
if (position === "bottom") {
115-
top = trigger.bottom + gap;
116-
// Check if tooltip would overflow bottom of viewport
117-
if (top + tooltip.height > viewportHeight) {
118-
// Flip to top
119-
finalPosition = "top";
120-
top = trigger.top - tooltip.height - gap;
114+
if (isHorizontalPosition) {
115+
// Horizontal positioning (left/right of trigger)
116+
top = trigger.top + trigger.height / 2 - tooltip.height / 2;
117+
118+
if (position === "left") {
119+
left = trigger.left - tooltip.width - gap;
120+
// Check if tooltip would overflow left of viewport
121+
if (left < 8) {
122+
finalPosition = "right";
123+
left = trigger.right + gap;
124+
}
125+
} else {
126+
// position === "right"
127+
left = trigger.right + gap;
128+
// Check if tooltip would overflow right of viewport
129+
if (left + tooltip.width > viewportWidth - 8) {
130+
finalPosition = "left";
131+
left = trigger.left - tooltip.width - gap;
132+
}
121133
}
134+
135+
// Vertical collision detection for horizontal tooltips
136+
top = Math.max(8, Math.min(viewportHeight - tooltip.height - 8, top));
122137
} else {
123-
// position === "top"
124-
top = trigger.top - tooltip.height - gap;
125-
// Check if tooltip would overflow top of viewport
126-
if (top < 0) {
127-
// Flip to bottom
128-
finalPosition = "bottom";
138+
// Vertical positioning (top/bottom of trigger) with collision detection
139+
if (position === "bottom") {
129140
top = trigger.bottom + gap;
141+
// Check if tooltip would overflow bottom of viewport
142+
if (top + tooltip.height > viewportHeight) {
143+
// Flip to top
144+
finalPosition = "top";
145+
top = trigger.top - tooltip.height - gap;
146+
}
147+
} else {
148+
// position === "top"
149+
top = trigger.top - tooltip.height - gap;
150+
// Check if tooltip would overflow top of viewport
151+
if (top < 0) {
152+
// Flip to bottom
153+
finalPosition = "bottom";
154+
top = trigger.bottom + gap;
155+
}
130156
}
131-
}
132157

133-
// Horizontal positioning based on align
134-
if (align === "left") {
135-
left = trigger.left;
136-
} else if (align === "right") {
137-
left = trigger.right - tooltip.width;
138-
} else {
139-
// center
140-
left = trigger.left + trigger.width / 2 - tooltip.width / 2;
158+
// Horizontal positioning based on align
159+
if (align === "left") {
160+
left = trigger.left;
161+
} else if (align === "right") {
162+
left = trigger.right - tooltip.width;
163+
} else {
164+
// center
165+
left = trigger.left + trigger.width / 2 - tooltip.width / 2;
166+
}
167+
168+
// Horizontal collision detection
169+
const minLeft = 8; // Min distance from viewport edge
170+
const maxLeft = viewportWidth - tooltip.width - 8;
171+
left = Math.max(minLeft, Math.min(maxLeft, left));
141172
}
142173

143-
// Horizontal collision detection
144-
const minLeft = 8; // Min distance from viewport edge
145-
const maxLeft = viewportWidth - tooltip.width - 8;
146-
const originalLeft = left;
147-
left = Math.max(minLeft, Math.min(maxLeft, left));
148-
149-
// Calculate arrow position - stays aligned with trigger even if tooltip shifts
150-
let arrowLeft: number;
151-
if (align === "center") {
152-
arrowLeft = trigger.left + trigger.width / 2 - left;
153-
} else if (align === "right") {
154-
arrowLeft = tooltip.width - 15; // 10px from right + 5px arrow width
174+
// Calculate arrow style based on final position
175+
const arrowStyle: React.CSSProperties = {};
176+
const finalIsHorizontal = finalPosition === "left" || finalPosition === "right";
177+
178+
if (finalIsHorizontal) {
179+
// Arrow on left or right side of tooltip, vertically centered
180+
arrowStyle.top = "50%";
181+
arrowStyle.transform = "translateY(-50%)";
182+
if (finalPosition === "left") {
183+
arrowStyle.left = "100%";
184+
arrowStyle.borderColor = "transparent transparent transparent #2d2d30";
185+
} else {
186+
arrowStyle.right = "100%";
187+
arrowStyle.borderColor = "transparent #2d2d30 transparent transparent";
188+
}
155189
} else {
156-
// left
157-
arrowLeft = Math.max(10, Math.min(originalLeft - left + 10, tooltip.width - 15));
190+
// Arrow on top or bottom of tooltip
191+
let arrowLeft: number;
192+
if (align === "center") {
193+
arrowLeft = trigger.left + trigger.width / 2 - left;
194+
} else if (align === "right") {
195+
arrowLeft = tooltip.width - 15;
196+
} else {
197+
arrowLeft = Math.max(10, Math.min(trigger.left - left + 10, tooltip.width - 15));
198+
}
199+
arrowStyle.left = `${arrowLeft}px`;
200+
arrowStyle[finalPosition === "bottom" ? "bottom" : "top"] = "100%";
201+
arrowStyle.borderColor =
202+
finalPosition === "bottom"
203+
? "transparent transparent #2d2d30 transparent"
204+
: "#2d2d30 transparent transparent transparent";
158205
}
159206

160207
// Update all state atomically to prevent flashing
@@ -166,14 +213,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
166213
visibility: "visible",
167214
opacity: 1,
168215
},
169-
arrowStyle: {
170-
left: `${arrowLeft}px`,
171-
[finalPosition === "bottom" ? "bottom" : "top"]: "100%",
172-
borderColor:
173-
finalPosition === "bottom"
174-
? "transparent transparent #2d2d30 transparent"
175-
: "#2d2d30 transparent transparent transparent",
176-
},
216+
arrowStyle,
177217
isPositioned: true,
178218
});
179219
};

0 commit comments

Comments
 (0)