Files
leaudit-platform-frontend/app/components/ui/Tooltip.tsx
T

295 lines
9.5 KiB
TypeScript

import { useState, useEffect, useRef, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import '../../styles/components/TooltipStyles.css';
// 提示框主题
export type TooltipTheme = 'dark' | 'light' | 'primary' | 'success' | 'warning' | 'error' | 'info';
// 提示框位置
export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right';
// 提示框属性
export interface TooltipProps {
children: ReactNode; // 触发元素
content: ReactNode; // 提示内容
theme?: TooltipTheme; // 主题样式
placement?: TooltipPlacement; // 显示位置
rich?: boolean; // 是否为富文本提示
header?: ReactNode; // 富文本提示标题
footer?: ReactNode; // 富文本提示页脚
trigger?: 'hover' | 'click'; // 触发方式
visible?: boolean; // 是否显示(受控模式)
showArrow?: boolean; // 是否显示箭头
maxWidth?: number | string; // 最大宽度
className?: string; // 自定义类名
onVisibleChange?: (visible: boolean) => void; // 显示状态变化回调
}
/**
* Tooltip 组件
*
* 提供增强的悬浮提示功能,支持多种主题、位置和富文本模式
*/
export function Tooltip({
children,
content,
theme = 'dark',
placement = 'top',
rich = false,
header,
footer,
trigger = 'hover',
visible: controlledVisible,
showArrow = true,
maxWidth = 320,
className = '',
onVisibleChange
}: TooltipProps) {
// 使用内部状态管理提示框显示状态(非受控模式)
const [visible, setVisible] = useState(false);
// 判断是否为受控组件
const isControlled = controlledVisible !== undefined;
// 获取实际显示状态
const isVisible = isControlled ? controlledVisible : visible;
// 引用DOM元素
const triggerRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
// 处理显示状态变化
const handleVisibleChange = (newVisible: boolean) => {
if (!isControlled) {
setVisible(newVisible);
}
onVisibleChange?.(newVisible);
};
// 触发器事件处理
const triggerEvents = trigger === 'hover'
? {
onMouseEnter: () => handleVisibleChange(true),
onMouseLeave: () => handleVisibleChange(false),
}
: {
onClick: () => handleVisibleChange(!isVisible),
};
// 更新提示框位置的函数
const updateTooltipPosition = () => {
if (!isVisible || !triggerRef.current || !tooltipRef.current) return;
// 获取触发元素位置
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
// 设置最大宽度
tooltipRef.current.style.maxWidth = typeof maxWidth === 'number'
? `${maxWidth}px`
: String(maxWidth);
// 计算各个方向的位置
let top = 0, left = 0;
let arrowTop = 0, arrowLeft = 0;
const arrowSize = 8; // 箭头大小
const gap = 10; // 提示框与触发元素的间距
switch (placement) {
case 'top':
left = triggerRect.left + (triggerRect.width / 2) - (tooltipRect.width / 2);
top = triggerRect.top - tooltipRect.height - arrowSize;
arrowLeft = tooltipRect.width / 2 - arrowSize;
arrowTop = tooltipRect.height;
break;
case 'bottom':
left = triggerRect.left + (triggerRect.width / 2) - (tooltipRect.width / 2);
top = triggerRect.bottom + arrowSize;
arrowLeft = tooltipRect.width / 2 - arrowSize;
arrowTop = -arrowSize;
break;
case 'left':
left = triggerRect.left - tooltipRect.width - arrowSize;
top = triggerRect.top + (triggerRect.height / 2) - (tooltipRect.height / 2);
arrowLeft = tooltipRect.width;
arrowTop = tooltipRect.height / 2 - arrowSize;
break;
case 'right':
left = triggerRect.right + arrowSize;
top = triggerRect.top + (triggerRect.height / 2) - (tooltipRect.height / 2);
arrowLeft = -arrowSize;
arrowTop = tooltipRect.height / 2 - arrowSize;
break;
}
// 确保提示框不超出视口
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// 调整位置,避免超出视口
if (left < 0) {
left = gap;
arrowLeft = Math.max(triggerRect.left + (triggerRect.width / 2) - left - arrowSize, arrowSize * 2);
} else if (left + tooltipRect.width > viewportWidth) {
left = viewportWidth - tooltipRect.width - gap;
arrowLeft = Math.min(triggerRect.left + (triggerRect.width / 2) - left - arrowSize, tooltipRect.width - arrowSize * 2);
}
if (top < 0) {
if (placement === 'top') {
// 如果上方放不下,切换到下方
top = triggerRect.bottom + arrowSize;
arrowTop = -arrowSize;
tooltipRef.current.classList.remove('tooltip-top');
tooltipRef.current.classList.add('tooltip-bottom');
} else {
top = gap;
arrowTop = Math.max(triggerRect.top + (triggerRect.height / 2) - top - arrowSize, arrowSize * 2);
}
} else if (top + tooltipRect.height > viewportHeight) {
if (placement === 'bottom') {
// 如果下方放不下,切换到上方
top = triggerRect.top - tooltipRect.height - arrowSize;
arrowTop = tooltipRect.height;
tooltipRef.current.classList.remove('tooltip-bottom');
tooltipRef.current.classList.add('tooltip-top');
} else {
top = viewportHeight - tooltipRect.height - gap;
arrowTop = Math.min(triggerRect.top + (triggerRect.height / 2) - top - arrowSize, tooltipRect.height - arrowSize * 2);
}
}
// 应用位置样式
tooltipRef.current.style.position = 'fixed';
tooltipRef.current.style.top = `${top}px`;
tooltipRef.current.style.left = `${left}px`;
tooltipRef.current.style.transform = 'none'; // 重置任何变换
// 定位箭头
const arrow = tooltipRef.current.querySelector('.tooltip-arrow') as HTMLElement;
if (arrow && showArrow) {
switch (placement) {
case 'top':
arrow.style.bottom = `-${arrowSize}px`;
arrow.style.left = `${arrowLeft}px`;
arrow.style.top = 'auto';
arrow.style.right = 'auto';
break;
case 'bottom':
arrow.style.top = `-${arrowSize}px`;
arrow.style.left = `${arrowLeft}px`;
arrow.style.bottom = 'auto';
arrow.style.right = 'auto';
break;
case 'left':
arrow.style.right = `-${arrowSize}px`;
arrow.style.top = `${arrowTop}px`;
arrow.style.bottom = 'auto';
arrow.style.left = 'auto';
break;
case 'right':
arrow.style.left = `-${arrowSize}px`;
arrow.style.top = `${arrowTop}px`;
arrow.style.bottom = 'auto';
arrow.style.right = 'auto';
break;
}
}
};
// 计算提示框位置
useEffect(() => {
if (!isVisible) return;
// 初始化位置
updateTooltipPosition();
// 添加滚动和调整大小事件监听器
window.addEventListener('scroll', updateTooltipPosition, true);
window.addEventListener('resize', updateTooltipPosition);
// 定期更新位置(处理内容动态变化的情况)
const intervalId = setInterval(updateTooltipPosition, 300);
// 清理函数
return () => {
window.removeEventListener('scroll', updateTooltipPosition, true);
window.removeEventListener('resize', updateTooltipPosition);
clearInterval(intervalId);
};
}, [isVisible, placement, maxWidth, showArrow]);
// 处理点击外部关闭
useEffect(() => {
if (trigger !== 'click' || !isVisible) return;
const handleClickOutside = (event: MouseEvent) => {
if (
triggerRef.current &&
!triggerRef.current.contains(event.target as Node) &&
tooltipRef.current &&
!tooltipRef.current.contains(event.target as Node)
) {
handleVisibleChange(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [trigger, isVisible]);
// 渲染提示框内容
const renderTooltipContent = () => {
if (rich) {
return (
<div className="tooltip-content">
{header && <div className="tooltip-header">{header}</div>}
<div className="tooltip-body">{content}</div>
{footer && <div className="tooltip-footer">{footer}</div>}
</div>
);
}
return (
<div className="tooltip-content">
<div>{content}</div>
</div>
);
};
// 生成提示框类名
const tooltipClassNames = [
'tooltip-container',
`tooltip-${placement}`,
`tooltip-${theme}`,
rich ? 'tooltip-rich' : '',
isVisible ? 'tooltip-visible' : '',
className
].filter(Boolean).join(' ');
// 使用Portal渲染提示框
const tooltipPortal = isVisible && typeof document !== 'undefined'
? createPortal(
<div
ref={tooltipRef}
className={tooltipClassNames}
style={{ maxWidth }}
>
{renderTooltipContent()}
{showArrow && <div className="tooltip-arrow"></div>}
</div>,
document.body
)
: null;
return (
<div className="tooltip-trigger" ref={triggerRef} {...triggerEvents}>
{children}
{tooltipPortal}
</div>
);
}
export default Tooltip;