当前位置: 首页 > news >正文

Vue3+Ts封装ToolTip组件(2.0版本)

本组件支持hover和click两种触发方式,需要更多的触发方式,可自行去扩展!!!

 

1.传递三个参数:

  • content:要展示的文本
  • position:文本出现的位置("top" | "top-start" | "top-end" | "bottom" | "bottom-start" | "bottom-end" | "left" | "right")

  • trigger:触发的方式("hover" | "click")

  • appendToBody:是否添加到body上去

2.使用方式:

<ToolTip
	content="测试ToolTip"
	:appendToBody="true"
	trigger="click"
	position="top"
>
	<span class="key-word">测试ToolTip</span>
</ToolTip>

3.封装的ToolTip组件详细代码如下: 

<template>
	<div
		class="tooltip-container"
		:class="{ 'tooltip-click': props.trigger === 'click' }"
		@mouseover="handleMouseOver"
		@mouseout="handleMouseOut"
		@click="handleClick"
		ref="triggerEl"
	>
		<slot></slot>
		<teleport to="body" :disabled="!props.appendToBody">
			<transition name="tooltip">
				<div v-if="isTooltipVisible" :class="tooltipClass" :style="tooltipStyle" ref="tooltipEl">
					<div class="tooltip-inner">
						{{ props.content }}
					</div>
				</div>
			</transition>
		</teleport>
	</div>
</template>

<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, CSSProperties } from "vue";

// 定义提示框可能出现的位置选项
const positionOptions = ["top", "top-start", "top-end", "bottom", "bottom-start", "bottom-end", "left", "right"] as const;
// 定义位置和触发方式的类型
type Position = (typeof positionOptions)[number];
type Trigger = ["hover" | "click"][number];
const defaultTrigger: Trigger = "hover";

// 定义组件的 props 接口
interface TooltipProps {
	content: string; // 提示框内容
	position?: Position; // 提示框位置
	trigger?: Trigger; // 触发方式
	appendToBody?: boolean; // 是否将提示框添加到 body 中
}

// 设置 props 的默认值
const props = withDefaults(defineProps<TooltipProps>(), {
	position: "top",
	trigger: defaultTrigger,
	appendToBody: false
});

// 创建响应式引用
const triggerEl = ref<HTMLElement | null>(null); // 触发元素引用
const tooltipEl = ref<HTMLElement | null>(null); // 提示框元素引用
const isTooltipVisible = ref(false); // 提示框是否可见
const tooltipPosition = ref({ top: "0px", left: "0px" }); // 提示框位置

// 处理点击外部事件
const handleClickOutside = (event: MouseEvent) => {
	if (props.trigger === "click" && isTooltipVisible.value) {
		const target = event.target as Node;
		if (triggerEl.value && tooltipEl.value && !triggerEl.value.contains(target) && !tooltipEl.value.contains(target)) {
			hideTooltip();
		}
	}
};

// 更新提示框位置的函数
const updatePosition = () => {
	if (!triggerEl.value || !tooltipEl.value || !props.appendToBody) return;

	// 获取各种位置和尺寸信息
	const triggerRect = triggerEl.value.getBoundingClientRect();
	const tooltipRect = tooltipEl.value.getBoundingClientRect();
	const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
	const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
	const viewportWidth = window.innerWidth;
	const viewportHeight = window.innerHeight;

	let top = 0;
	let left = 0;
	const gap = 4; // 设置间隙

	// 计算提示框位置的核心函数
	const calculatePosition = () => {
		switch (props.position) {
			// 处理顶部位置的情况(top, top-start, top-end 三种)
			case "top":
			case "top-start":
			case "top-end": {
				// 检查顶部空间是否足够放置提示框(触发元素顶部位置是否大于提示框高度+间隙)
				if (triggerRect.top > tooltipRect.height + gap) {
					// 设置提示框的垂直位置:触发元素顶部位置 - 提示框高度 - 间隙
					top = triggerRect.top + scrollTop - tooltipRect.height - gap;

					// 根据不同的顶部对齐方式计算水平位置
					if (props.position === "top") {
						// top:水平居中对齐
						left =
							triggerRect.left + // 触发元素的左边界
							scrollLeft + // 加上页面水平滚动距离
							(triggerRect.width - tooltipRect.width) / 2; // 居中对齐的偏移量
					} else if (props.position === "top-start") {
						// top-start:左对齐
						left = triggerRect.left + scrollLeft; // 直接与触发元素左边界对齐
					} else {
						// top-end:右对齐
						left =
							triggerRect.left + // 触发元素的左边界
							scrollLeft + // 加上页面水平滚动距离
							triggerRect.width - // 加上触发元素的宽度
							tooltipRect.width; // 减去提示框宽度,实现右对齐
					}
				} else {
					// 如果顶部空间不足,自动切换到底部显示
					top = triggerRect.bottom + scrollTop + gap; // 设置到触发元素底部
					left = calculateHorizontalPosition(); // 重新计算水平位置
					tooltipEl.value?.classList.remove(props.position); // 移除原有位置类名
					tooltipEl.value?.classList.add("bottom"); // 添加底部位置类名
				}
				break;
			}

			// 处理底部位置的情况(bottom, bottom-start, bottom-end 三种)
			case "bottom":
			case "bottom-start":
			case "bottom-end": {
				// 设置提示框的垂直位置:触发元素底部 + 间隙
				top = triggerRect.bottom + scrollTop + gap;
				// 根据不同的底部对齐方式计算水平位置
				if (props.position === "bottom") {
					// bottom:水平居中对齐
					left =
						triggerRect.left + // 触发元素的左边界
						scrollLeft + // 加上页面水平滚动距离
						(triggerRect.width - tooltipRect.width) / 2; // 居中对齐的偏移量
				} else if (props.position === "bottom-start") {
					// bottom-start:左对齐
					left = triggerRect.left + scrollLeft; // 直接与触发元素左边界对齐
				} else {
					// bottom-end:右对齐
					left =
						triggerRect.left + // 触发元素的左边界
						scrollLeft + // 加上页面水平滚动距离
						triggerRect.width - // 加上触发元素的宽度
						tooltipRect.width; // 减去提示框宽度,实现右对齐
				}
				break;
			}

			// 处理左侧位置的情况
			case "left": {
				const arrowWidth = 18; // 箭头的宽度
				// 检查左侧空间是否足够(触发元素左侧位置是否大于提示框宽度+间隙+箭头宽度)
				if (triggerRect.left > tooltipRect.width + gap + arrowWidth) {
					// 设置水平位置:触发元素左侧 - 提示框宽度 - 间隙 - 箭头宽度
					left = triggerRect.left + scrollLeft - tooltipRect.width - gap - arrowWidth;
					// 垂直居中对齐
					top = triggerRect.top + scrollTop + (triggerRect.height - tooltipRect.height) / 2;
				} else {
					// 如果左侧空间不足,自动切换到右侧显示
					left = triggerRect.right + scrollLeft + gap; // 设置到触发元素右侧
					// 保持垂直居中
					top = triggerRect.top + scrollTop + (triggerRect.height - tooltipRect.height) / 2;
					tooltipEl.value?.classList.remove("left"); // 移除左侧位置类名
					tooltipEl.value?.classList.add("right"); // 添加右侧位置类名
				}
				break;
			}

			// 处理右侧位置的情况
			case "right": {
				const arrowWidth = 18; // 箭头的宽度
				// 检查右侧空间是否足够(触发元素右侧位置+提示框宽度+间隙是否小于视口宽度)
				if (triggerRect.right + tooltipRect.width + gap <= viewportWidth) {
					// 设置水平位置:触发元素右侧 + 间隙
					left = triggerRect.right + scrollLeft + gap;
					// 垂直居中对齐
					top = triggerRect.top + scrollTop + (triggerRect.height - tooltipRect.height) / 2;
				} else {
					// 如果右侧空间不足,自动切换到左侧显示
					// 确保左侧位置不小于间隙值
					left = Math.max(gap, triggerRect.left + scrollLeft - tooltipRect.width - gap - arrowWidth);
					// 保持垂直居中
					top = triggerRect.top + scrollTop + (triggerRect.height - tooltipRect.height) / 2;
					tooltipEl.value?.classList.remove("right"); // 移除右侧位置类名
					tooltipEl.value?.classList.add("left"); // 添加左侧位置类名
				}
				break;
			}
		}
	};

	// 计算水平位置,确保提示框在视口内
	const calculateHorizontalPosition = () => {
		let calculatedLeft = triggerRect.left + scrollLeft + (triggerRect.width - tooltipRect.width) / 2;
		if (calculatedLeft < 0) {
			calculatedLeft = gap;
		}
		if (calculatedLeft + tooltipRect.width > viewportWidth) {
			calculatedLeft = viewportWidth - tooltipRect.width - gap;
		}
		return calculatedLeft;
	};

	calculatePosition();

	// 确保提示框在视口范围内
	if (top < scrollTop) {
		top = scrollTop + gap;
	} else if (top + tooltipRect.height > scrollTop + viewportHeight) {
		top = scrollTop + viewportHeight - tooltipRect.height - gap;
	}
	left = Math.max(gap, Math.min(left, viewportWidth - tooltipRect.width - gap));

	// 更新提示框位置
	tooltipPosition.value = {
		top: `${Math.round(top)}px`,
		left: `${Math.round(left)}px`
	};
};

// 显示提示框
const showTooltip = () => {
	isTooltipVisible.value = true;
	setTimeout(updatePosition, 0);
};

// 隐藏提示框
const hideTooltip = () => {
	isTooltipVisible.value = false;
};

// 处理鼠标移入事件
const handleMouseOver = () => {
	if (props.trigger === "hover") {
		showTooltip();
	}
};

// 处理鼠标移出事件
const handleMouseOut = () => {
	if (props.trigger === "hover") {
		hideTooltip();
	}
};

// 处理点击事件
const handleClick = () => {
	if (props.trigger === "click") {
		isTooltipVisible.value = !isTooltipVisible.value;
		if (isTooltipVisible.value) {
			setTimeout(updatePosition, 0);
		}
	}
};

// 组件挂载时添加事件监听
onMounted(() => {
	window.addEventListener("scroll", updatePosition);
	window.addEventListener("resize", updatePosition);
	document.addEventListener("click", handleClickOutside);
});

// 组件卸载前移除事件监听
onBeforeUnmount(() => {
	window.removeEventListener("scroll", updatePosition);
	window.removeEventListener("resize", updatePosition);
	document.removeEventListener("click", handleClickOutside);
});

// 监听触发方式的变化
watch(
	() => props.trigger,
	newTrigger => {
		if (newTrigger === "click" && isTooltipVisible.value) {
			hideTooltip();
		}
	},
	{ immediate: true }
);

// 计算提示框的 class
const tooltipClass = computed(() => {
	return `tooltip-content ${props.position} ${isTooltipVisible.value ? "active" : ""}`;
});

// 计算提示框的样式
const tooltipStyle = computed<CSSProperties>(() => {
	if (!props.appendToBody) return {};
	return {
		position: "fixed",
		top: tooltipPosition.value.top,
		left: tooltipPosition.value.left,
		zIndex: 9999
	};
});
</script>

<style lang="scss" scoped>
@use "sass:math";
$basicW: 6px;
$arrowSize: 6px;
$backgroundColor: #454545;

.tooltip-container {
	width: 100%;
	position: relative;
	display: inline-block;
	z-index: 1;
}

.tooltip-content {
	position: absolute;
	z-index: 9999;
	pointer-events: none;

	.tooltip-inner {
		position: relative;
		background-color: #454545;
		color: #fff;
		padding: 8px 12px;
		border-radius: 4px;
		font-size: 14px;
		line-height: 1.4;
		white-space: normal;
		min-width: max-content;
		max-width: 300px;
		width: auto;
		word-wrap: break-word;
		box-shadow: 2px 2px 8px rgb(0 0 0);
		&::before {
			position: absolute;
			content: "";
			width: 0;
			height: 0;
			border: $arrowSize solid transparent;
		}
	}

	&.active {
		pointer-events: auto;
	}

	&.top,
	&.top-start,
	&.top-end {
		padding-bottom: $arrowSize;

		.tooltip-inner::before {
			bottom: -$arrowSize * 2;
			border-top-color: $backgroundColor;
		}
	}

	&.bottom,
	&.bottom-start,
	&.bottom-end {
		padding-top: $arrowSize;

		.tooltip-inner::before {
			top: -$arrowSize * 2;
			border-bottom-color: $backgroundColor;
		}
	}

	&.left {
		padding-right: $arrowSize;

		.tooltip-inner::before {
			right: -$arrowSize * 2;
			border-left-color: $backgroundColor;
		}
	}

	&.right {
		padding-left: $arrowSize;

		.tooltip-inner::before {
			left: -$arrowSize * 2;
			border-right-color: $backgroundColor;
		}
	}

	&.top,
	&.bottom {
		.tooltip-inner::before {
			left: 50%;
			transform: translateX(-50%);
		}
	}

	&.top-start,
	&.bottom-start {
		.tooltip-inner::before {
			left: $arrowSize;
		}
	}

	&.top-end,
	&.bottom-end {
		.tooltip-inner::before {
			right: $arrowSize;
		}
	}

	&.left,
	&.right {
		.tooltip-inner::before {
			top: 50%;
			transform: translateY(-50%);
		}
	}
}

// 动画相关样式
.tooltip-enter-active,
.tooltip-leave-active {
	transition: opacity 0.2s ease, transform 0.2s ease-out;
}

.tooltip-enter-from,
.tooltip-leave-to {
	opacity: 0;
	transform: scale(0.95);
}

.tooltip-enter-to,
.tooltip-leave-from {
	opacity: 1;
	transform: scale(1);
}
</style>

相关文章:

  • Vue.js 中 v-if 的使用及其原理
  • Nginx漏洞复现
  • andorid 查找没有使用的资源
  • Navicat和PLSQL在oracle 使用语句报ORA-00911: 无效字符
  • Mysql专题篇章
  • SQL Server 数据库邮件配置失败:SMTP 连接与权限问题
  • zookeeper平滑扩缩容
  • 蓝桥杯 C/C++ 组历届真题合集速刷(二)
  • 数字IC后端项目典型问题之后端实战项目问题记录
  • Linux驱动开发:SPI驱动开发原理
  • sql-labs靶场 less-1
  • fabric.js基础使用
  • CrystalDiskInfo电脑硬盘监控工具 v9.6.0中文绿色便携版
  • 平台算法暗战:ebay欧洲站搜索词长度同比缩短2.3字符的应对策略
  • Java 泛型的逆变与协变:深入理解类型安全与灵活性
  • Windows系统中Miniforge安装后的环境变量配置与conda命令不可用解决方案
  • Redis主从复制:告别单身Redis!
  • 深入探索Scala:从基础到进阶的全面总结
  • VectorBT量化入门系列:第二章 VectorBT核心功能与数据处理
  • deep research开源框架:WebThinker
  • 人民日报评外卖平台被约谈:摒弃恶性竞争,实现行业健康发展
  • 《蛮好的人生》:为啥人人都爱这个不完美的“大女主”
  • 国务院关税税则委:调整对原产于美国的进口商品加征关税措施
  • 哈马斯表示已释放一名美以双重国籍被扣押人员
  • 《新时代的中国国家安全》白皮书(全文)
  • 新疆交通运输厅厅长西尔艾力·外力履新吐鲁番市市长候选人