Skip to content

repositioning the profile hover-card#1575

Merged
ragnep merged 4 commits intomainfrom
profile-hover-card-position-fix
Oct 28, 2025
Merged

repositioning the profile hover-card#1575
ragnep merged 4 commits intomainfrom
profile-hover-card-position-fix

Conversation

@ragnep
Copy link
Copy Markdown
Contributor

@ragnep ragnep commented Oct 28, 2025

Summary by CodeRabbit

  • Bug Fixes

    • Tooltip positioning now recalculates reliably on layout changes, window resize/scroll, and element size changes to maintain correct alignment.
    • Improved stability when tooltips are shown/hidden or when target elements resize.
  • Chores / Documentation

    • Added a tracking ticket and project-state entry documenting the work to harden tooltip positioning and validation steps.

Signed-off-by: ragnep <ragneinfo@gmail.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Oct 28, 2025

Warning

Rate limit exceeded

@ragnep has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 21 minutes and 14 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between fabef60 and 4ce85a9.

📒 Files selected for processing (1)
  • components/utils/tooltip/CustomTooltip.tsx (7 hunks)

Walkthrough

The CustomTooltip component adds layout-aware positioning: ResizeObserver for child and tooltip, window resize/scroll listeners, isBrowser guards, merged child event handlers with safe ref assignment, useLayoutEffect-driven recalculation, and improved cleanup for observers and timers.

Changes

Cohort / File(s) Summary
Tooltip positioning enhancements
components/utils/tooltip/CustomTooltip.tsx
Added ResizeObserver-based updates for child and tooltip; created refs for observers and nodes; introduced useLayoutEffect for layout-driven recalculation; added isBrowser guards for DOM ops; added global resize/scroll listeners; merged internal and external child event handlers via new TooltipChildHandlers type and mergeHandlers; added assignChildNode and setRefValue utilities; replaced direct child with clonedChild that injects merged handlers; added cleanup to disconnect observers and clear timers

Sequence Diagram(s)

sequenceDiagram
    participant Mount as Component Mount
    participant Layout as useLayoutEffect
    participant Child as Child Node
    participant Tooltip as Tooltip Node
    participant ResizeObs as ResizeObserver
    participant Window as Window (resize/scroll)
    participant Reposition as Recalculate Position
    participant Cleanup as Cleanup/Unmount

    Mount->>Layout: mount -> setup refs & handlers
    Layout->>ResizeObs: create observers for Child & Tooltip
    ResizeObs->>Reposition: size change -> trigger recalculation
    Child->>Reposition: child resize/measure data
    Tooltip->>Reposition: tooltip resize/measure data
    Window->>Reposition: resize/scroll events trigger recalculation
    Reposition->>Reposition: compute placement (isBrowser guards)
    Cleanup->>ResizeObs: disconnect observers
    Cleanup->>Window: remove listeners & clear timers
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Areas to inspect closely:
    • ref assignment utilities (setRefValue, assignChildNode) and handling non-HTMLElement refs
    • merged event handler logic to ensure original child handlers are preserved
    • ResizeObserver lifecycle and disconnect/cleanup paths
    • useLayoutEffect dependency array and isBrowser guards for SSR safety

Possibly related PRs

Suggested reviewers

  • GelatoGenesis

Poem

🐇 I hopped in code with careful cheer,

I watch each size and whisper near.
Handlers join and refs held tight,
I nudge the tooltip just right.
Reposition safe — a rabbit's delight.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The pull request title "repositioning the profile hover-card" is related to the changeset's primary objective. The main code change involves significant enhancements to the CustomTooltip component (which appears to be the underlying mechanism for profile hover-cards), including improvements to positioning logic with ResizeObserver integration, viewport calculations, and event handling. The title captures the user-facing goal of improving profile hover-card positioning, though it doesn't explicitly mention that the changes involve hardening the tooltip component's positioning robustness or indicate the technical scope of the modifications. The title is partially related to the changeset—it refers to a real aspect of the change but doesn't fully communicate the technical depth of the implementation.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (8)
components/utils/tooltip/CustomTooltip.tsx (8)

49-52: Timer typing and guards for DOM/Node compatibility

Use ReturnType and guard clearTimeout to avoid ambient NodeJS typings conflicts across browser builds.

-  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);
@@
-    clearTimeout(hideTimer.current);
+    if (hideTimer.current !== undefined) clearTimeout(hideTimer.current);
@@
-    clearTimeout(showTimer.current);
+    if (showTimer.current !== undefined) clearTimeout(showTimer.current);
@@
-      clearTimeout(showTimer.current);
-      clearTimeout(hideTimer.current);
+      if (showTimer.current !== undefined) clearTimeout(showTimer.current);
+      if (hideTimer.current !== undefined) clearTimeout(hideTimer.current);

Also applies to: 213-216, 218-221, 296-303


73-86: Observer can go stale if the child DOM node changes while visible

If the trigger element is replaced (e.g., conditional render), the existing ResizeObserver will keep observing the old node until visibility flips. Rebind on ref changes.

-  const assignChildNode = useCallback(
-    (node: HTMLElement | null) => {
+  const assignChildNode = useCallback(
+    (node: HTMLElement | null) => {
       childRef.current = node;
@@
-      setRefValue(originalRef, node);
+      setRefValue(originalRef, node);
+      // Rebind observer if tooltip is visible and observer exists
+      if (isVisible && childObserverRef.current && node) {
+        try {
+          childObserverRef.current.disconnect();
+          childObserverRef.current.observe(node);
+        } catch {}
+      }
     },
-    [originalRef, setRefValue]
+    [originalRef, setRefValue, isVisible]
   );

Also applies to: 256-278


283-294: Throttle scroll/resize reposition with rAF

Calling calculatePosition on every scroll/resize can jank. Batch per frame.

-    const handleReposition = () => {
-      calculatePosition();
-    };
+    let rafId: number | null = null;
+    const handleReposition = () => {
+      if (rafId !== null) return;
+      rafId = requestAnimationFrame(() => {
+        rafId = null;
+        calculatePosition();
+      });
+    };
@@
-    return () => {
+    return () => {
       window.removeEventListener("resize", handleReposition);
       window.removeEventListener("scroll", handleReposition, true);
+      if (rafId !== null) cancelAnimationFrame(rafId);
     };

77-82: Avoid console noise in production

Gate the warning so production builds stay clean.

-      if (node && typeof node.getBoundingClientRect !== "function") {
-        console.warn(
-          "[CustomTooltip] Child ref is not an HTMLElement; tooltip positioning may fail."
-        );
-      }
+      if (process.env.NODE_ENV !== "production") {
+        if (node && typeof (node as any).getBoundingClientRect !== "function") {
+          console.warn("[CustomTooltip] Child ref is not an HTMLElement; tooltip positioning may fail.");
+        }
+      }

223-232: useLayoutEffect deps: remove unused isBrowser or early-return

isBrowser is not used in the effect body. Either remove it from deps or add a guard. Prefer removing to avoid unnecessary re-runs.

-  }, [isVisible, calculatePosition, isBrowser]);
+  }, [isVisible, calculatePosition]);

3-10: Add aria linkage for accessible tooltips

Expose an id on the tooltip and reference it from the trigger via aria-describedby. Helps screen readers and aligns with a11y best practices.

 import React, {
   useState,
   useEffect,
   useRef,
   useCallback,
   useLayoutEffect,
   MutableRefObject,
+  useId,
 } from "react";
@@
 }: CustomTooltipProps) {
+  const tooltipId = useId();
@@
   const clonedChild = React.cloneElement(
     childElement,
     {
       ref: assignChildNode,
+      "aria-describedby": isVisible ? tooltipId : undefined,
@@
         <div
           ref={tooltipRef}
+          id={tooltipId}
           role="tooltip"

Also applies to: 40-46, 305-334, 340-345


338-340: Styling via CSS Modules in TSX; guideline prefers Tailwind

This component uses SCSS module classes. Our .tsx guideline favors TailwindCSS. Consider migrating classes (tooltip container, content, arrow) to Tailwind utilities or exposing a Tailwind-friendly API.

As per coding guidelines

Also applies to: 340-368


193-209: Minor: isBrowser guard already implied by "use client"

calculatePosition early-returns on !isBrowser; fine, but including isBrowser in the callback deps forces re-memoization with no behavior change. Safe to drop for stability.

-  }, [getOptimalPlacement, calculateInitialPosition, adjustPositionForViewport, calculateArrowPosition, isBrowser]);
+  }, [getOptimalPlacement, calculateInitialPosition, adjustPositionForViewport, calculateArrowPosition]);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1724500 and 6a083dd.

📒 Files selected for processing (1)
  • components/utils/tooltip/CustomTooltip.tsx (7 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursorrules)

**/*.{ts,tsx}: Do not include any comments in the code
Use react-query for data fetching
Always add readonly before props

Use TypeScript across the codebase

Files:

  • components/utils/tooltip/CustomTooltip.tsx
**/*.tsx

📄 CodeRabbit inference engine (.cursorrules)

**/*.tsx: Use FontAwesome for icons
Use TailwindCSS for styling

Use React functional components with hooks for UI components

Files:

  • components/utils/tooltip/CustomTooltip.tsx
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Analyze (javascript-typescript)
🔇 Additional comments (1)
components/utils/tooltip/CustomTooltip.tsx (1)

305-334: Ref injection requires a DOM-ref-capable child

If children are function components without forwardRef (or not passing ref through under React 19’s “ref as prop”), assignChildNode won’t receive a DOM node; positioning will no-op.

Please confirm that consumers always pass a DOM element or a component that forwards ref to a DOM node. If not, I can propose a wrapper or event-target fallback to capture the element.

Signed-off-by: ragnep <ragneinfo@gmail.com>
Signed-off-by: ragnep <ragneinfo@gmail.com>
Signed-off-by: ragnep <ragneinfo@gmail.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (9)
codex/tickets/TKT-0020.md (1)

28-29: Add the primary PR link.

Populate the placeholder with this PR for traceability.

- - Primary PR: _(add when available)_
+ - Primary PR: [#1575](https://github.com/6529-Collections/6529seize-frontend/pull/1575)
codex/STATE.md (1)

26-26: Link the active PR in the PRs column.

Keeps the board actionable.

-| TKT-0020 | Harden CustomTooltip positioning robustness | In-Progress | P1 | openai-assistant | — | 2025-10-28 |
+| TKT-0020 | Harden CustomTooltip positioning robustness | In-Progress | P1 | openai-assistant | [#1575](https://github.com/6529-Collections/6529seize-frontend/pull/1575) | 2025-10-28 |
components/utils/tooltip/CustomTooltip.tsx (7)

53-54: Unify timer typings for browser/Node.

Prefer ReturnType to avoid NodeJS.Timeout mismatches in DOM builds.

-  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);

24-29: Support non-HTML children (e.g., SVG) and improve ref typing.

Widen event/ref types from HTMLElement to Element; keeps measurement working on any Element with getBoundingClientRect.

-type TooltipChildHandlers = {
-  onMouseEnter?: React.MouseEventHandler<HTMLElement>;
-  onMouseLeave?: React.MouseEventHandler<HTMLElement>;
-  onFocus?: React.FocusEventHandler<HTMLElement>;
-  onBlur?: React.FocusEventHandler<HTMLElement>;
+type TooltipChildHandlers = {
+  onMouseEnter?: React.MouseEventHandler<Element>;
+  onMouseLeave?: React.MouseEventHandler<Element>;
+  onFocus?: React.FocusEventHandler<Element>;
+  onBlur?: React.FocusEventHandler<Element>;
 };
 
-const childRef = useRef<HTMLElement>(null);
+const childRef = useRef<Element>(null);
 
-const originalRef = (childElement as React.ReactElement & {
-  ref?: React.Ref<HTMLElement>;
+const originalRef = (childElement as React.ReactElement & {
+  ref?: React.Ref<Element>;
 }).ref;
 
-const setRefValue = useCallback((ref: React.Ref<HTMLElement> | undefined, node: HTMLElement | null) => {
+const setRefValue = useCallback((ref: React.Ref<Element> | undefined, node: Element | null) => {
   if (!ref) return;
   if (typeof ref === "function") {
     ref(node);
     return;
   }
   try {
-    (ref as React.MutableRefObject<HTMLElement | null>).current = node;
+    (ref as React.MutableRefObject<Element | null>).current = node;
   } catch {
     console.warn("[CustomTooltip] Failed to assign ref (may be read-only)");
   }
 }, []);
 
-const assignChildNode = useCallback(
-  (node: HTMLElement | null) => {
+const assignChildNode = useCallback(
+  (node: Element | 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);
   },
   [originalRef, setRefValue]
 );
 
   const clonedChild = React.cloneElement(
     childElement,
     {
       ref: assignChildNode,
-      onMouseEnter: mergeHandlers<React.MouseEvent<HTMLElement>>(
+      onMouseEnter: mergeHandlers<React.MouseEvent<Element>>(
         () => {
           show();
         },
         childElement.props.onMouseEnter
       ),
-      onMouseLeave: mergeHandlers<React.MouseEvent<HTMLElement>>(
+      onMouseLeave: mergeHandlers<React.MouseEvent<Element>>(
         () => {
           hide();
         },
         childElement.props.onMouseLeave
       ),
-      onFocus: mergeHandlers<React.FocusEvent<HTMLElement>>(
+      onFocus: mergeHandlers<React.FocusEvent<Element>>(
         () => {
           show();
         },
         childElement.props.onFocus
       ),
-      onBlur: mergeHandlers<React.FocusEvent<HTMLElement>>(
+      onBlur: mergeHandlers<React.FocusEvent<Element>>(
         () => {
           hide();
         },
         childElement.props.onBlur
       ),
-    } as React.Attributes & TooltipChildHandlers
+    } as React.Attributes & TooltipChildHandlers
   );

Also applies to: 51-61, 62-74, 75-89, 315-339


90-97: Call consumer handlers first, then internal ones.

Preserves child semantics if they preventDefault/stopPropagation.

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

285-301: Throttle scroll/resize reflows with requestAnimationFrame.

Prevents excessive synchronous layout reads during fast scrolling.

-  const childObserverRef: MutableRefObject<ResizeObserver | null> = useRef(null);
-  const tooltipObserverRef: MutableRefObject<ResizeObserver | null> = useRef(null);
+  const childObserverRef: MutableRefObject<ResizeObserver | null> = useRef(null);
+  const tooltipObserverRef: MutableRefObject<ResizeObserver | null> = useRef(null);
+  const repositionRaf = useRef<number | null>(null);
 
   useEffect(() => {
     if (!isVisible) return;
     if (!isBrowser) return;
 
-    const handleReposition = () => {
-      calculatePosition();
-    };
+    const handleReposition = () => {
+      if (repositionRaf.current != null) return;
+      repositionRaf.current = requestAnimationFrame(() => {
+        repositionRaf.current = null;
+        calculatePosition();
+      });
+    };
 
     win.addEventListener("resize", handleReposition);
     win.addEventListener("scroll", handleReposition, true);
 
     return () => {
       win.removeEventListener("resize", handleReposition);
       win.removeEventListener("scroll", handleReposition, true);
+      if (repositionRaf.current != null) {
+        cancelAnimationFrame(repositionRaf.current);
+        repositionRaf.current = null;
+      }
     };
   }, [isVisible, calculatePosition]);
 
   useEffect(() => {
     return () => {
       clearTimeout(showTimer.current);
       clearTimeout(hideTimer.current);
       childObserverRef.current?.disconnect();
       tooltipObserverRef.current?.disconnect();
+      if (repositionRaf.current != null) {
+        cancelAnimationFrame(repositionRaf.current);
+        repositionRaf.current = null;
+      }
     };
   }, []);

Also applies to: 55-57, 302-310


3-10: Improve accessibility with an explicit aria-describedby link.

Screen readers can associate the trigger with tooltip content.

 import React, {
   useState,
   useEffect,
   useRef,
   useCallback,
   useLayoutEffect,
   MutableRefObject,
+  useId,
 } from "react";
@@
 export default function CustomTooltip({
   children,
   content,
   placement = "top",
   delayShow = 700,
   delayHide = 0,
   disabled = false,
   offset = 8,
 }: CustomTooltipProps) {
+  const tooltipId = useId();
@@
   const clonedChild = React.cloneElement(
     childElement,
     {
       ref: assignChildNode,
+      'aria-describedby': [childElement.props?.['aria-describedby'], tooltipId].filter(Boolean).join(' ') || undefined,
@@
-    } as React.Attributes & TooltipChildHandlers
+    } as React.Attributes & TooltipChildHandlers & React.AriaAttributes
   );
@@
-        <div
+        <div
           ref={tooltipRef}
+          id={tooltipId}
           role="tooltip"

Also applies to: 311-340, 346-356


14-22: Optional: support interactive hover-cards. Verify needed for profile cards.

If the profile hover-card contains interactive elements, pointerEvents: 'none' blocks interaction and causes immediate hide on mouseout from trigger. Suggest an opt-in interactive mode.

 interface CustomTooltipProps {
   readonly children: React.ReactElement;
   readonly content: React.ReactNode;
   readonly placement?: "top" | "bottom" | "left" | "right" | "auto";
   readonly delayShow?: number;
   readonly delayHide?: number;
   readonly disabled?: boolean;
   readonly offset?: number;
+  readonly interactive?: boolean;
 }
@@
-          style={{
+          onMouseEnter={interactive ? () => show() : undefined}
+          onMouseLeave={interactive ? () => hide() : undefined}
+          style={{
             position: 'fixed',
             left: `${position.x}px`,
             top: `${position.y}px`,
             zIndex: 999999,
-            pointerEvents: 'none',
+            pointerEvents: interactive ? 'auto' : 'none',
           }}

Also applies to: 346-356


69-73: Gate console warnings to development.

Avoid noisy logs in production.

-    } catch {
-      console.warn("[CustomTooltip] Failed to assign ref (may be read-only)");
+    } catch {
+      if (process.env.NODE_ENV !== "production") {
+        console.warn("[CustomTooltip] Failed to assign ref (may be read-only)");
+      }
     }
@@
-      if (node && typeof node.getBoundingClientRect !== "function") {
-        console.warn(
-          "[CustomTooltip] Child ref is not an HTMLElement; tooltip positioning may fail."
-        );
+      if (node && typeof (node as any).getBoundingClientRect !== "function") {
+        if (process.env.NODE_ENV !== "production") {
+          console.warn("[CustomTooltip] Child ref is not an HTMLElement; tooltip positioning may fail.");
+        }
       }

Also applies to: 79-84

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6a083dd and fabef60.

📒 Files selected for processing (3)
  • codex/STATE.md (1 hunks)
  • codex/tickets/TKT-0020.md (1 hunks)
  • components/utils/tooltip/CustomTooltip.tsx (7 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursorrules)

**/*.{ts,tsx}: Do not include any comments in the code
Use react-query for data fetching
Always add readonly before props

Use TypeScript across the codebase

Files:

  • components/utils/tooltip/CustomTooltip.tsx
**/*.tsx

📄 CodeRabbit inference engine (.cursorrules)

**/*.tsx: Use FontAwesome for icons
Use TailwindCSS for styling

Use React functional components with hooks for UI components

Files:

  • components/utils/tooltip/CustomTooltip.tsx
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Analyze (javascript-typescript)

@sonarqubecloud
Copy link
Copy Markdown

@ragnep ragnep merged commit 2b87bdb into main Oct 28, 2025
8 checks passed
@ragnep ragnep deleted the profile-hover-card-position-fix branch October 28, 2025 13:06
@coderabbitai coderabbitai Bot mentioned this pull request Mar 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants