From 415d0facec905c84bf1b4c5c68f9dda11410c35c Mon Sep 17 00:00:00 2001 From: Ivan Tur <49621041+turfle@users.noreply.github.com> Date: Sat, 10 Jan 2026 04:49:05 +0200 Subject: [PATCH] feat(ui): add reusable Tooltip component and integrate with settings (#538) Co-authored-by: Ivan Tur <49621041+buiiz@users.noreply.github.com> --- src/components/ui/SettingContainer.tsx | 15 ++-- src/components/ui/Tooltip.tsx | 114 +++++++++++++++++++++++++ src/components/ui/index.ts | 1 + 3 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 src/components/ui/Tooltip.tsx diff --git a/src/components/ui/SettingContainer.tsx b/src/components/ui/SettingContainer.tsx index ce24b46..1777f40 100644 --- a/src/components/ui/SettingContainer.tsx +++ b/src/components/ui/SettingContainer.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; +import { Tooltip } from "./Tooltip"; interface SettingContainerProps { title: string; @@ -90,12 +91,11 @@ export const SettingContainer: React.FC = ({ /> {showTooltip && ( -
+

{description}

-
-
+ )} @@ -164,16 +164,11 @@ export const SettingContainer: React.FC = ({ /> {showTooltip && ( -
+

{description}

-
-
+ )} diff --git a/src/components/ui/Tooltip.tsx b/src/components/ui/Tooltip.tsx new file mode 100644 index 0000000..1c1d208 --- /dev/null +++ b/src/components/ui/Tooltip.tsx @@ -0,0 +1,114 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +type TooltipPosition = "top" | "bottom"; + +interface TooltipCoords { + top: number; + left: number; + arrowLeft: number; + actualPosition: TooltipPosition; +} + +interface TooltipProps { + targetRef: React.RefObject; + position?: TooltipPosition; + children: React.ReactNode; +} + +const TOOLTIP_WIDTH = 200; +const VIEWPORT_PADDING = 12; +const GAP = 8; +const ARROW_MARGIN = 12; +const DEFAULT_HEIGHT = 60; + +export const Tooltip: React.FC = ({ + targetRef, + position = "top", + children, +}) => { + const [coords, setCoords] = useState(null); + const tooltipRef = useRef(null); + + const updatePosition = useCallback(() => { + if (!targetRef.current) return; + + const targetRect = targetRef.current.getBoundingClientRect(); + const tooltipHeight = tooltipRef.current?.offsetHeight || DEFAULT_HEIGHT; + + let actualPosition = position; + let top: number; + + if (position === "top") { + const spaceAbove = targetRect.top - tooltipHeight - GAP; + if (spaceAbove < VIEWPORT_PADDING) { + actualPosition = "bottom"; + top = targetRect.bottom + GAP; + } else { + top = targetRect.top - GAP - tooltipHeight; + } + } else { + const spaceBelow = + window.innerHeight - targetRect.bottom - tooltipHeight - GAP; + if (spaceBelow < VIEWPORT_PADDING) { + actualPosition = "top"; + top = targetRect.top - GAP - tooltipHeight; + } else { + top = targetRect.bottom + GAP; + } + } + + const targetCenter = targetRect.left + targetRect.width / 2; + let left = targetCenter - TOOLTIP_WIDTH / 2; + + if (left < VIEWPORT_PADDING) { + left = VIEWPORT_PADDING; + } else if (left + TOOLTIP_WIDTH > window.innerWidth - VIEWPORT_PADDING) { + left = window.innerWidth - TOOLTIP_WIDTH - VIEWPORT_PADDING; + } + + const arrowLeft = Math.min( + Math.max(targetCenter - left, ARROW_MARGIN), + TOOLTIP_WIDTH - ARROW_MARGIN, + ); + + setCoords({ top, left, arrowLeft, actualPosition }); + }, [targetRef, position]); + + useEffect(() => { + updatePosition(); + + window.addEventListener("scroll", updatePosition, true); + window.addEventListener("resize", updatePosition); + + return () => { + window.removeEventListener("scroll", updatePosition, true); + window.removeEventListener("resize", updatePosition); + }; + }, [updatePosition]); + + const arrowClasses = + coords?.actualPosition === "top" ? "top-full" : "bottom-full rotate-180"; + + return createPortal( +
+ {children} +
+
, + document.body, + ); +}; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 71f2e28..11a10f1 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -5,3 +5,4 @@ export { SettingContainer } from "./SettingContainer"; export { SettingsGroup } from "./SettingsGroup"; export { TextDisplay } from "./TextDisplay"; export { Textarea } from "./Textarea"; +export { Tooltip } from "./Tooltip";