本组件支持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>