420 lines
15 KiB
TypeScript
420 lines
15 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; // 显示状态变化回调
|
|
fixedPlacement?: boolean; // 是否固定显示位置,不自动切换
|
|
maxHeight?: number | string; // 最大高度,超出将显示滚动条
|
|
scrollable?: boolean; // 是否可滚动
|
|
}
|
|
|
|
/**
|
|
* Tooltip 组件
|
|
*
|
|
* 提供增强的悬浮提示功能,支持多种主题、位置和富文本模式
|
|
*/
|
|
export function Tooltip({
|
|
children,
|
|
content,
|
|
theme = 'dark',
|
|
placement = 'top',
|
|
rich = false,
|
|
header,
|
|
footer,
|
|
trigger = 'hover',
|
|
visible: controlledVisible,
|
|
showArrow = true,
|
|
maxWidth = 320,
|
|
className = '',
|
|
onVisibleChange,
|
|
fixedPlacement = false, // 默认不固定位置
|
|
maxHeight = 300, // 默认最大高度300px
|
|
scrollable = true // 默认允许滚动
|
|
}: 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 contentRef = useRef<HTMLDivElement>(null);
|
|
|
|
// 保存当前实际显示位置
|
|
const [actualPlacement, setActualPlacement] = useState<TooltipPlacement>(placement);
|
|
|
|
// 处理显示状态变化
|
|
const handleVisibleChange = (newVisible: boolean) => {
|
|
if (!isControlled) {
|
|
setVisible(newVisible);
|
|
}
|
|
onVisibleChange?.(newVisible);
|
|
|
|
// 当显示状态变为false时,重置actualPlacement为初始placement
|
|
if (!newVisible) {
|
|
setActualPlacement(placement);
|
|
}
|
|
};
|
|
|
|
// 触发器事件处理
|
|
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);
|
|
|
|
// 如果内容可滚动,设置最大高度,但仅应用于内容容器而不是整个tooltip
|
|
if (scrollable && contentRef.current) {
|
|
contentRef.current.style.maxHeight = typeof maxHeight === 'number'
|
|
? `${maxHeight}px`
|
|
: String(maxHeight);
|
|
|
|
// 确保只有内容区域显示滚动条,整个tooltip容器不滚动
|
|
contentRef.current.style.overflowY = 'auto';
|
|
tooltipRef.current.style.overflow = 'visible';
|
|
}
|
|
|
|
// 计算各个方向的位置
|
|
let top = 0, left = 0;
|
|
let arrowTop = 0, arrowLeft = 0;
|
|
const arrowSize = 8; // 箭头大小
|
|
const gap = 10; // 提示框与触发元素的间距
|
|
|
|
// 使用actualPlacement而不是placement来计算位置
|
|
let currentPlacement = actualPlacement;
|
|
|
|
switch (currentPlacement) {
|
|
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 - 8;
|
|
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);
|
|
}
|
|
|
|
// 如果fixedPlacement为true,则强制使用指定的位置,不自动切换
|
|
if (!fixedPlacement) {
|
|
// 自动调整位置逻辑
|
|
if (top < 0) {
|
|
if (currentPlacement === 'top') {
|
|
// 如果上方放不下,切换到下方
|
|
top = triggerRect.bottom + arrowSize - 8;
|
|
arrowTop = -arrowSize;
|
|
|
|
// 更新实际位置为bottom
|
|
setActualPlacement('bottom');
|
|
currentPlacement = 'bottom';
|
|
|
|
// 更新CSS类
|
|
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 (currentPlacement === 'bottom') {
|
|
// 如果下方放不下,切换到上方
|
|
top = triggerRect.top - tooltipRect.height - arrowSize;
|
|
arrowTop = tooltipRect.height;
|
|
|
|
// 更新实际位置为top
|
|
setActualPlacement('top');
|
|
currentPlacement = 'top';
|
|
|
|
// 更新CSS类
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 处理左右方向的切换
|
|
if (left < 0 && currentPlacement === 'left') {
|
|
// 如果左边放不下,切换到右边
|
|
left = triggerRect.right + arrowSize;
|
|
arrowLeft = -arrowSize;
|
|
|
|
// 更新实际位置为right
|
|
setActualPlacement('right');
|
|
currentPlacement = 'right';
|
|
|
|
// 更新CSS类
|
|
tooltipRef.current.classList.remove('tooltip-left');
|
|
tooltipRef.current.classList.add('tooltip-right');
|
|
} else if (left + tooltipRect.width > viewportWidth && currentPlacement === 'right') {
|
|
// 如果右边放不下,切换到左边
|
|
left = triggerRect.left - tooltipRect.width - arrowSize;
|
|
arrowLeft = tooltipRect.width;
|
|
|
|
// 更新实际位置为left
|
|
setActualPlacement('left');
|
|
currentPlacement = 'left';
|
|
|
|
// 更新CSS类
|
|
tooltipRef.current.classList.remove('tooltip-right');
|
|
tooltipRef.current.classList.add('tooltip-left');
|
|
}
|
|
} else {
|
|
// 固定位置,但仍然需要确保在视口内可见
|
|
if (currentPlacement === 'top') {
|
|
// 如果提示框超出了顶部,增加偏移
|
|
if (top < 0) {
|
|
const originalTop = top;
|
|
top = gap;
|
|
// 调整箭头位置,使其指向触发元素
|
|
arrowTop = arrowTop + (originalTop - top);
|
|
}
|
|
} else if (currentPlacement === 'bottom') {
|
|
// 如果提示框超出了底部,减少偏移
|
|
if (top + tooltipRect.height > viewportHeight) {
|
|
const originalTop = top;
|
|
top = viewportHeight - tooltipRect.height - gap;
|
|
// 调整箭头位置,使其指向触发元素
|
|
arrowTop = arrowTop - (originalTop - top);
|
|
}
|
|
} else if (currentPlacement === 'left') {
|
|
// 如果提示框超出了左侧,增加偏移
|
|
if (left < 0) {
|
|
const originalLeft = left;
|
|
left = gap;
|
|
// 调整箭头位置,使其指向触发元素
|
|
arrowLeft = arrowLeft + (originalLeft - left);
|
|
}
|
|
} else if (currentPlacement === 'right') {
|
|
// 如果提示框超出了右侧,减少偏移
|
|
if (left + tooltipRect.width > viewportWidth) {
|
|
const originalLeft = left;
|
|
left = viewportWidth - tooltipRect.width - gap;
|
|
// 调整箭头位置,使其指向触发元素
|
|
arrowLeft = arrowLeft - (originalLeft - left);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 应用位置样式
|
|
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 (currentPlacement) {
|
|
case 'top':
|
|
arrow.style.bottom = `-${arrowSize}px`;
|
|
arrow.style.left = `${arrowLeft}px`;
|
|
arrow.style.top = 'auto';
|
|
arrow.style.right = 'auto';
|
|
// 设置箭头指向下方的样式
|
|
arrow.className = 'tooltip-arrow tooltip-arrow-bottom';
|
|
break;
|
|
case 'bottom':
|
|
arrow.style.top = `-${arrowSize}px`;
|
|
arrow.style.left = `${arrowLeft}px`;
|
|
arrow.style.bottom = 'auto';
|
|
arrow.style.right = 'auto';
|
|
// 设置箭头指向上方的样式
|
|
arrow.className = 'tooltip-arrow tooltip-arrow-top';
|
|
break;
|
|
case 'left':
|
|
arrow.style.right = `-${arrowSize}px`;
|
|
arrow.style.top = `${arrowTop}px`;
|
|
arrow.style.bottom = 'auto';
|
|
arrow.style.left = 'auto';
|
|
// 设置箭头指向右方的样式
|
|
arrow.className = 'tooltip-arrow tooltip-arrow-right';
|
|
break;
|
|
case 'right':
|
|
arrow.style.left = `-${arrowSize}px`;
|
|
arrow.style.top = `${arrowTop}px`;
|
|
arrow.style.bottom = 'auto';
|
|
arrow.style.right = 'auto';
|
|
// 设置箭头指向左方的样式
|
|
arrow.className = 'tooltip-arrow tooltip-arrow-left';
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
// 当组件首次挂载或placement改变时,重置actualPlacement
|
|
useEffect(() => {
|
|
setActualPlacement(placement);
|
|
}, [placement]);
|
|
|
|
// 计算提示框位置
|
|
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, fixedPlacement, actualPlacement]);
|
|
|
|
// 处理点击外部关闭
|
|
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 = () => {
|
|
// 根据是否启用滚动来确定内容容器类名
|
|
const contentClassName = scrollable ? 'tooltip-content-inner tooltip-scrollable' : 'tooltip-content-inner';
|
|
|
|
if (rich) {
|
|
return (
|
|
<div className="tooltip-content" ref={contentRef}>
|
|
{header && <div className="tooltip-header">{header}</div>}
|
|
<div className={contentClassName}>{content}</div>
|
|
{footer && <div className="tooltip-footer">{footer}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="tooltip-content" ref={contentRef}>
|
|
<div className={contentClassName}>{content}</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 生成提示框类名
|
|
const tooltipClassNames = [
|
|
'tooltip-container',
|
|
`tooltip-${actualPlacement}`, // 使用actualPlacement而不是placement
|
|
`tooltip-${theme}`,
|
|
rich ? 'tooltip-rich' : '',
|
|
isVisible ? 'tooltip-visible' : '',
|
|
fixedPlacement ? 'tooltip-fixed-placement' : '',
|
|
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;
|