Element Plus Tooltip 在滚动容器中位置超出问题解决方案
一、问题描述
在树组件(el-tree、el-tree-select)中使用 el-tooltip 显示节点提示信息时,当容器支持横向滚动时,滚动条拖动后 tooltip 提示框会超出容器边界,无法始终保持在容器内部显示。
二、问题原因
el-tooltip默认基于触发元素定位,不会自动感知滚动容器的边界- 当触发元素被滚动到容器边缘时,tooltip 仍然按照原始位置计算,导致超出容器
三、解决方案
核心技术:popper-options 配置边界约束
使用 Popper.js 的 preventOverflow 和 flip 修饰符,将 tooltip 的边界约束在指定容器内。
关键代码
javascript
const containerRef = ref<HTMLElement | null>(null)
const tooltipPopperOptions = computed(() => ({
modifiers: [
{
name: 'preventOverflow',
options: {
boundary: containerRef.value || undefined,
},
},
{
name: 'flip',
options: {
boundary: containerRef.value || undefined,
},
},
],
}))
vue
<el-tooltip :popper-options="tooltipPopperOptions" placement="top">
<div>内容</div>
</el-tooltip>
四、不同场景的实现方式
场景一:平铺树组件(el-tree)
特点:容器元素与树组件在同一层级,可直接通过 ref 获取
实现步骤:
- 给树容器添加 ref
vue
<div class="tree-container" ref="treeContainerRef">
<el-tree>...</el-tree>
</div>
- 定义容器引用
javascript
const treeContainerRef = ref<HTMLElement | null>(null)
- 传递给子组件
vue
<YlAvatorCard :boundaryEl="treeContainerRef" ... />
场景二:下拉选择组件(el-tree-select)
特点:下拉面板通过 teleport 挂载到 body,容器元素需要动态获取
关键点 :不能使用 el-tree-select 的 ref 作为边界,因为下拉面板是独立挂载的 DOM 元素
实现步骤:
- 定义边界容器引用
javascript
const dialogBoundaryEl = ref<HTMLElement | null>(null)
- 在下拉框打开时动态获取
javascript
const dialogVisibleChange = (show: boolean) => {
if (show) {
nextTick(() => {
// 获取下拉面板作为边界容器
dialogBoundaryEl.value = document.querySelector('.area-or-group-tree')
})
}
}
- 传递给子组件
vue
<el-tree-select @visible-change="dialogVisibleChange" ...>
<template #default="{ data }">
<YlAvatorCard :boundaryEl="dialogBoundaryEl" ... />
</template>
</el-tree-select>
五、子组件封装(ylAvatorCard)
Props 定义
typescript
defineProps<{
// ... 其他 props
boundaryEl?: HTMLElement | null // tooltip 边界容器元素
}>()
Tooltip 配置
vue
<el-tooltip
:disabled="!props.disabled || !props.content"
:content="props.content"
placement="top"
v-model:visible="isTooltipVisible"
virtual-triggering
:virtual-ref="cardRef"
:popper-options="tooltipPopperOptions"
> </el-tooltip>
javascript
const cardRef = ref<HTMLElement | null>(null)
const tooltipPopperOptions = computed(() => ({
modifiers: [
{
name: 'preventOverflow',
options: {
boundary: props.boundaryEl || cardRef.value || undefined,
},
},
{
name: 'flip',
options: {
boundary: props.boundaryEl || cardRef.value || undefined,
},
},
],
}))
六、关键技术点总结
| 技术 | 作用 |
|---|---|
virtual-triggering |
启用虚拟触发模式,配合 virtual-ref 使用 |
virtual-ref |
指定虚拟触发的 DOM 元素 |
preventOverflow |
防止 tooltip 溢出指定边界 |
flip |
当空间不足时自动翻转位置 |
nextTick |
确保 DOM 渲染完成后再获取元素 |
七、注意事项
- 边界容器选择:必须选择实际包含触发元素的容器,而非组件实例
- teleport 组件:下拉面板等 teleport 组件的边界容器需要通过选择器动态获取
- 事件清理:动态添加的事件监听需要在组件销毁或下拉框关闭时移除
代码样例(更精细化处理箭头的位置)
javascript
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tooltip 位置修复演示</title>
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css">
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus"></script>
<style>
body {
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
width: 400px;
height: 400px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
display: flex;
flex-direction: column;
overflow-x: auto;
overflow-y: hidden;
}
.item {
padding: 12px 16px;
border-bottom: 1px solid #ebeef5;
white-space: nowrap;
min-width: max-content;
}
.item:last-child {
border-bottom: none;
}
.item-text {
cursor: pointer;
color: #409eff;
}
.tip {
margin-bottom: 16px;
padding: 12px;
background: #fdf6ec;
border-radius: 4px;
color: #e6a23c;
font-size: 14px;
}
</style>
</head>
<body>
<div id="app">
<h3 style="margin-bottom: 16px;">Tooltip 位置修复演示</h3>
<div class="tip">
提示:tooltip 内容和箭头始终保持在容器可视区域内
</div>
<div class="container" ref="containerRef">
<template v-if="isReady">
<div class="item" v-for="(item, index) in textList" :key="index">
<el-tooltip
:content="item"
placement="top"
effect="dark"
:popper-options="tooltipPopperOptions"
>
<span class="item-text">{{ item }}</span>
</el-tooltip>
</div>
</template>
</div>
</div>
<script>
const { createApp, ref, computed, onMounted, nextTick } = Vue;
const app = createApp({
setup() {
// 容器引用
const containerRef = ref(null);
// 是否准备好渲染
const isReady = ref(false);
// 文本列表
const textList = ref([
'这是一段短文本',
'这是一段稍微长一点的文本内容,用于测试显示效果',
'这是一段非常长的文本内容,它将会超出容器的宽度范围,从而触发横向滚动条的显示,用户可以通过横向滚动来查看完整的文本内容,鼠标悬浮时会显示完整的提示信息',
'这是最后一段文本,长度也比较适中,用于演示不同长度文本在容器中的显示效果,同时验证横向滚动功能的正确性'
]);
// 自定义 modifier:限制箭头位置在容器内
const arrowBoundaryModifier = {
name: 'arrowBoundary',
enabled: true,
phase: 'main',
fn({ state }) {
if (!containerRef.value) return;
const containerRect = containerRef.value.getBoundingClientRect();
const arrowElement = state.elements.arrow;
if (arrowElement && state.modifiersData.arrow) {
const arrowData = state.modifiersData.arrow;
const popperRect = state.rects.popper;
const popperX = state.modifiersData.popperOffsets.x;
// 计算箭头在视口中的实际位置
const arrowX = popperX + arrowData.x;
// 计算容器边界
const containerLeft = containerRect.left + 12; // 加 padding
const containerRight = containerRect.right - 12;
// 限制箭头位置在容器内
if (arrowX < containerLeft) {
arrowData.x = containerLeft - popperX;
} else if (arrowX > containerRight) {
arrowData.x = containerRight - popperX;
}
}
},
};
// Popper 配置:使用容器作为边界约束
const tooltipPopperOptions = computed(() => {
if (!containerRef.value) return {};
return {
modifiers: [
{
name: 'preventOverflow',
options: {
boundary: containerRef.value,
padding: 8,
tether: false,
},
},
{
name: 'flip',
options: {
boundary: containerRef.value,
padding: 8,
fallbackPlacements: ['bottom'],
},
},
{
name: 'arrow',
options: {
padding: 12,
},
},
arrowBoundaryModifier,
],
};
});
// 组件挂载后设置 ready
onMounted(() => {
nextTick(() => {
isReady.value = true;
});
});
return {
containerRef,
isReady,
textList,
tooltipPopperOptions
};
}
});
app.use(ElementPlus);
app.mount('#app');
</script>
</body>
</html>