Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex/STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ This table is the single source of truth for active and historical tickets. Keep
| TKT-0017 | Stabilize Waves modal tests for app routing | In-Progress | P1 | openai-assistant | — | 2025-10-27 |
| TKT-0018 | Clean up UrlGuardHooks export | In-Progress | P1 | openai-assistant | — | 2025-10-28 |
| TKT-0019 | Make Wave card fully clickable | In-Progress | P1 | openai-assistant | — | 2025-10-27 |
| TKT-0020 | Harden CustomTooltip positioning robustness | In-Progress | P1 | openai-assistant | — | 2025-10-28 |

## Usage Guidelines

Expand Down
33 changes: 33 additions & 0 deletions codex/tickets/TKT-0020.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
created: 2025-10-28
id: TKT-0020
owner: openai-assistant
priority: P1
status: In-Progress
title: Harden CustomTooltip positioning robustness
---

## Context

> Strengthen the reusable tooltip wrapper so hover cards render in the correct position on first reveal, even as content or viewport changes occur, while maintaining existing UX. Earlier investigation highlighted subtle layout drift when profile cards mounted.

## Plan

- [x] Audit current measurement flow, ref handling, and observer lifecycle for `CustomTooltip`.
- [x] Refactor positioning logic to use stable browser references, forward refs safely, and add responsive observers without altering behaviour.
- [ ] Capture validation notes, including automated checks and any required manual verification steps.

## Acceptance

- [ ] `CustomTooltip` maintains existing hover/focus behaviour on desktop and touch devices.
- [ ] Tests, lint, and type-check suites pass without new regressions.
- [ ] Manual smoke test confirms initial hover positioning anchors correctly.

## Links

- Primary PR: _(add when available)_
- Follow-ups: _(add as needed)_

## Log

- 2025-10-28T12:24:37Z – Logged tooltip robustness work from hover-card investigation; initial implementation underway.
258 changes: 214 additions & 44 deletions components/utils/tooltip/CustomTooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
"use client"

import React, { useState, useEffect, useRef, useCallback } from "react";
import React, {
useState,
useEffect,
useRef,
useCallback,
useLayoutEffect,
MutableRefObject,
} from "react";
import { createPortal } from "react-dom";
import styles from "./CustomTooltip.module.scss";

Expand All @@ -14,6 +21,17 @@ interface CustomTooltipProps {
readonly offset?: number;
}

type TooltipChildHandlers = {
onMouseEnter?: React.MouseEventHandler<HTMLElement>;
onMouseLeave?: React.MouseEventHandler<HTMLElement>;
onFocus?: React.FocusEventHandler<HTMLElement>;
onBlur?: React.FocusEventHandler<HTMLElement>;
};

const globalScope = globalThis as typeof globalThis & { window?: Window };
const win = globalScope.window ?? null;
const isBrowser = win !== null;

export default function CustomTooltip({
children,
content,
Expand All @@ -29,22 +47,80 @@ export default function CustomTooltip({
const [actualPlacement, setActualPlacement] = useState<"top" | "bottom" | "left" | "right">(
placement === "auto" ? "bottom" : placement
);

const childRef = useRef<HTMLElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const showTimer = useRef<NodeJS.Timeout | undefined>(undefined);
const hideTimer = useRef<NodeJS.Timeout | undefined>(undefined);
const showTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const childObserverRef: MutableRefObject<ResizeObserver | null> = useRef(null);
const tooltipObserverRef: MutableRefObject<ResizeObserver | null> = useRef(null);
const childElement = React.Children.only(children) as React.ReactElement<TooltipChildHandlers>;
const originalRef = (childElement as React.ReactElement & {
ref?: React.Ref<HTMLElement>;
}).ref;

const setRefValue = useCallback((ref: React.Ref<HTMLElement> | undefined, node: HTMLElement | null) => {
if (!ref) return;
if (typeof ref === "function") {
ref(node);
return;
}
try {
(ref as React.MutableRefObject<HTMLElement | null>).current = node;
} catch {
if (typeof console !== "undefined") {
console.warn("[CustomTooltip] Failed to assign ref (may be read-only)");
}
}
}, []);

const assignChildNode = useCallback(
(node: HTMLElement | null) => {
childRef.current = node;

if (node && typeof node.getBoundingClientRect !== "function") {
console.warn(
"[CustomTooltip] Child ref is not an HTMLElement; tooltip positioning may fail."
);
}

setRefValue(originalRef, node);

if (isVisible && childObserverRef.current) {
try {
childObserverRef.current.disconnect();
if (node) {
childObserverRef.current.observe(node);
}
} catch {
// Ignore observer errors
}
}
},
[originalRef, setRefValue, isVisible]
);

const mergeHandlers = useCallback(
<E extends React.SyntheticEvent>(ourHandler: (event: E) => void, theirHandler?: (event: E) => void) =>
(event: E) => {
ourHandler(event);
theirHandler?.(event);
},
[]
);

const getOptimalPlacement = useCallback((childRect: DOMRect, tooltipRect: DOMRect) => {
if (placement !== "auto") return placement;

const padding = 8;
const arrowSize = 8;
const viewportHeight = win ? win.innerHeight : 0;
const viewportWidth = win ? win.innerWidth : 0;
const spaces = {
top: childRect.top - padding,
bottom: window.innerHeight - childRect.bottom - padding,
bottom: viewportHeight - childRect.bottom - padding,
left: childRect.left - padding,
right: window.innerWidth - childRect.right - padding
right: viewportWidth - childRect.right - padding
};

const requiredVerticalSpace = tooltipRect.height + offset + arrowSize;
Expand Down Expand Up @@ -89,24 +165,26 @@ export default function CustomTooltip({

const adjustPositionForViewport = useCallback((position: { x: number, y: number }, childRect: DOMRect, tooltipRect: DOMRect, targetPlacement: string) => {
const padding = 8;
const viewportHeight = win ? win.innerHeight : 0;
const viewportWidth = win ? win.innerWidth : 0;
let { x, y } = position;
let finalPlacement = targetPlacement;

// Keep tooltip within viewport bounds horizontally
const maxX = window.innerWidth - tooltipRect.width - padding;
const maxX = viewportWidth - tooltipRect.width - padding;
x = Math.max(padding, Math.min(x, maxX));

// Adjust vertical position to prevent overlap
if (targetPlacement === "top" && y < padding) {
finalPlacement = "bottom";
y = childRect.bottom + offset;
} else if (targetPlacement === "bottom" && y + tooltipRect.height > window.innerHeight - padding) {
} else if (targetPlacement === "bottom" && y + tooltipRect.height > viewportHeight - padding) {
finalPlacement = "top";
y = childRect.top - tooltipRect.height - offset;
} else if (targetPlacement === "left" && x < padding) {
finalPlacement = "right";
x = childRect.right + offset;
} else if (targetPlacement === "right" && x + tooltipRect.width > window.innerWidth - padding) {
} else if (targetPlacement === "right" && x + tooltipRect.width > viewportWidth - padding) {
finalPlacement = "left";
x = childRect.left - tooltipRect.width - offset;
}
Expand All @@ -133,6 +211,7 @@ export default function CustomTooltip({

const calculatePosition = useCallback(() => {
if (!childRef.current || !tooltipRef.current) return;
if (!isBrowser) return;

const childRect = childRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
Expand All @@ -149,59 +228,150 @@ export default function CustomTooltip({

const show = useCallback(() => {
if (disabled) return;
clearTimeout(hideTimer.current);
if (hideTimer.current !== undefined) {
clearTimeout(hideTimer.current);
}
showTimer.current = setTimeout(() => {
setIsVisible(true);
}, delayShow);
}, [disabled, delayShow]);

const hide = useCallback(() => {
clearTimeout(showTimer.current);
if (showTimer.current !== undefined) {
clearTimeout(showTimer.current);
}
hideTimer.current = setTimeout(() => setIsVisible(false), delayHide);
}, [delayHide]);

useLayoutEffect(() => {
if (!isVisible) return;

const frame = requestAnimationFrame(() => {
calculatePosition();
});

return () => cancelAnimationFrame(frame);
}, [isVisible, calculatePosition]);

useEffect(() => {
if (isVisible) {
// Double RAF ensures DOM is fully updated before measuring
requestAnimationFrame(() => {
requestAnimationFrame(() => {
calculatePosition();
});
});
if (!isVisible) return;
if (!tooltipRef.current) return;
if (!isBrowser) return;

if (typeof ResizeObserver === "undefined") {
calculatePosition();
return;
}

const observer = new ResizeObserver(() => {
calculatePosition();
});

tooltipObserverRef.current = observer;
observer.observe(tooltipRef.current);

return () => {
observer.disconnect();
tooltipObserverRef.current = null;
};
}, [isVisible, calculatePosition]);

useEffect(() => {
if (!isVisible) return;
if (!childRef.current) return;
if (!isBrowser) return;

if (typeof ResizeObserver === "undefined") {
calculatePosition();
return;
}

const observer = new ResizeObserver(() => {
calculatePosition();
});

childObserverRef.current = observer;
observer.observe(childRef.current);

return () => {
observer.disconnect();
childObserverRef.current = null;
};
}, [isVisible, calculatePosition]);

useEffect(() => {
if (!isVisible) return;
if (!isBrowser) return;

let rafId: number | null = null;
const handleReposition = () => {
if (rafId !== null) return;
rafId = win.requestAnimationFrame(() => {
rafId = null;
calculatePosition();
});
};

win.addEventListener("resize", handleReposition);
win.addEventListener("scroll", handleReposition, true);

return () => {
clearTimeout(showTimer.current);
clearTimeout(hideTimer.current);
win.removeEventListener("resize", handleReposition);
win.removeEventListener("scroll", handleReposition, true);
if (rafId !== null) {
win.cancelAnimationFrame(rafId);
}
};
}, [isVisible, calculatePosition]);

useEffect(() => {
return () => {
if (showTimer.current !== undefined) {
clearTimeout(showTimer.current);
}
if (hideTimer.current !== undefined) {
clearTimeout(hideTimer.current);
}
childObserverRef.current?.disconnect();
tooltipObserverRef.current?.disconnect();
};
}, []);

const child = React.cloneElement(children, {
ref: childRef,
onMouseEnter: (e: React.MouseEvent) => {
show();
(children.props as any).onMouseEnter?.(e);
},
onMouseLeave: (e: React.MouseEvent) => {
hide();
(children.props as any).onMouseLeave?.(e);
},
onFocus: (e: React.FocusEvent) => {
show();
(children.props as any).onFocus?.(e);
},
onBlur: (e: React.FocusEvent) => {
hide();
(children.props as any).onBlur?.(e);
},
} as any);
const clonedChild = React.cloneElement(
childElement,
{
ref: assignChildNode,
onMouseEnter: mergeHandlers<React.MouseEvent<HTMLElement>>(
() => {
show();
},
childElement.props.onMouseEnter
),
onMouseLeave: mergeHandlers<React.MouseEvent<HTMLElement>>(
() => {
hide();
},
childElement.props.onMouseLeave
),
onFocus: mergeHandlers<React.FocusEvent<HTMLElement>>(
() => {
show();
},
childElement.props.onFocus
),
onBlur: mergeHandlers<React.FocusEvent<HTMLElement>>(
() => {
hide();
},
childElement.props.onBlur
),
} as React.Attributes & TooltipChildHandlers
);

return (
<>
{child}
{isVisible && typeof document !== 'undefined' && createPortal(
{clonedChild}
{isVisible && typeof document !== "undefined" && createPortal(
<div
ref={tooltipRef}
role="tooltip"
Expand Down Expand Up @@ -235,4 +405,4 @@ export default function CustomTooltip({
)}
</>
);
}
}