/
lizardking
/
react_project
Обзор
Документация
Войти
/
lizardking
/
react_project
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
react19
src/shared/ui/Tooltip/Tooltip.tsx
150 строк
4 KB
LizardKing
Task 8 - React Portal
10 ноя 2025, 16:20
10 ноя 2025, 16:20
683d199
Код
Авторство
О чём код?
import React, { useEffect, useRef, useState, useCallback } from 'react' import { createPortal } from 'react-dom' import type { Coordinates } from './types' import { Position } from './types' type TooltipProps = { children: React.ReactNode content: React.ReactNode position?: Position offset?: number } const ANIMATION_MS = 160 function getTooltipRoot(): HTMLElement | null { return document.getElementById('tooltip-root') } function calculateTooltipCoordinates( triggerElement: HTMLElement, position: Position, offset: number, ): Coordinates { const rect = triggerElement.getBoundingClientRect() const scrollLeft = window.scrollX const scrollTop = window.scrollY const horizontalCenter = rect.left + rect.width / 2 + scrollLeft const verticalCenter = rect.top + rect.height / 2 + scrollTop switch (position) { case Position.Top: return { top: rect.top + scrollTop - offset, left: horizontalCenter, transform: 'translate(-50%, -100%)' } case Position.Bottom: return { top: rect.bottom + scrollTop + offset, left: horizontalCenter, transform: 'translate(-50%, 0)' } case Position.Left: return { top: verticalCenter, left: rect.left + scrollLeft - offset, transform: 'translate(-100%, -50%)' } case Position.Right: return { top: verticalCenter, left: rect.right + scrollLeft + offset, transform: 'translate(0, -50%)' } default: return { top: rect.bottom + scrollTop + offset, left: horizontalCenter, transform: 'translate(-50%, 0)' } } } export function Tooltip({ children, content, position = Position.Top, offset = 8, }: TooltipProps) { const triggerRef = useRef<HTMLSpanElement | null>(null) const [isMounted, setIsMounted] = useState(false) // портал смонтирован const [isShown, setIsShown] = useState(false) // состояние анимации (opacity/scale) const [coords, setCoords] = useState<Coordinates | null>(null) const updatePosition = useCallback(() => { const triggerElement = triggerRef.current if (!triggerElement) return setCoords(calculateTooltipCoordinates(triggerElement, position, offset)) }, [position, offset]) useEffect(() => { if (!isMounted) return updatePosition() function handleViewportChange() { updatePosition() } window.addEventListener('scroll', handleViewportChange, true) window.addEventListener('resize', handleViewportChange, true) return () => { window.removeEventListener('scroll', handleViewportChange, true) window.removeEventListener('resize', handleViewportChange, true) } }, [isMounted, updatePosition]) const tooltipRoot = getTooltipRoot() if (!tooltipRoot) { return ( <span ref={triggerRef} className="relative inline-block"> {children} </span> ) } function handleMouseEnter() { if (!isMounted) { setIsMounted(true) requestAnimationFrame(() => setIsShown(true)) return } setIsShown(true) } function handleMouseLeave() { setIsShown(false) window.setTimeout(() => setIsMounted(false), ANIMATION_MS) } function handleFocus() { handleMouseEnter() } function handleBlur() { handleMouseLeave() } const portalNode = isMounted && coords ? createPortal( <div role="tooltip" className="fixed z-[1000] pointer-events-none rounded-lg bg-gray-800 text-white text-xs px-2.5 py-2 shadow-lg transition-[opacity,transform] duration-150 ease-out will-change-transform" style={{ top: coords.top, left: coords.left, transform: `${coords.transform} scale(${isShown ? 1 : 0.98})`, opacity: isShown ? 1 : 0, }}> {content} </div>, tooltipRoot, ) : null return ( <span ref={triggerRef} className="relative inline-block" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onFocus={handleFocus} onBlur={handleBlur}> {children} {portalNode} </span> ) }