tooltip-position-solution

Element Plus Tooltip 在滚动容器中位置超出问题解决方案

一、问题描述

在树组件(el-treeel-tree-select)中使用 el-tooltip 显示节点提示信息时,当容器支持横向滚动时,滚动条拖动后 tooltip 提示框会超出容器边界,无法始终保持在容器内部显示。

二、问题原因

  1. el-tooltip 默认基于触发元素定位,不会自动感知滚动容器的边界
  2. 当触发元素被滚动到容器边缘时,tooltip 仍然按照原始位置计算,导致超出容器

三、解决方案

核心技术:popper-options 配置边界约束

使用 Popper.js 的 preventOverflowflip 修饰符,将 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 获取

实现步骤

  1. 给树容器添加 ref
vue 复制代码
<div class="tree-container" ref="treeContainerRef">
  <el-tree>...</el-tree>
</div>
  1. 定义容器引用
javascript 复制代码
const treeContainerRef = ref<HTMLElement | null>(null)
  1. 传递给子组件
vue 复制代码
<YlAvatorCard :boundaryEl="treeContainerRef" ... />

场景二:下拉选择组件(el-tree-select)

特点:下拉面板通过 teleport 挂载到 body,容器元素需要动态获取

关键点 :不能使用 el-tree-select 的 ref 作为边界,因为下拉面板是独立挂载的 DOM 元素

实现步骤

  1. 定义边界容器引用
javascript 复制代码
const dialogBoundaryEl = ref<HTMLElement | null>(null)
  1. 在下拉框打开时动态获取
javascript 复制代码
const dialogVisibleChange = (show: boolean) => {
  if (show) {
    nextTick(() => {
      // 获取下拉面板作为边界容器
      dialogBoundaryEl.value = document.querySelector('.area-or-group-tree')
    })
  }
}
  1. 传递给子组件
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"
>&nbsp;</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 渲染完成后再获取元素

七、注意事项

  1. 边界容器选择:必须选择实际包含触发元素的容器,而非组件实例
  2. teleport 组件:下拉面板等 teleport 组件的边界容器需要通过选择器动态获取
  3. 事件清理:动态添加的事件监听需要在组件销毁或下拉框关闭时移除

代码样例(更精细化处理箭头的位置)

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>
相关推荐
LXXgalaxy2 小时前
`摸鱼决策轮盘`【vue3+ts前端实战小项目】
前端
这是个栗子2 小时前
关于 TypeScript 的介绍
前端·javascript·typescript
亿元程序员2 小时前
亿元Cocos小游戏实战合集指南和答疑
前端
开开心心就好2 小时前
伪装文件历史记录!修改时间的黑科技软件
java·前端·科技·r语言·edge·pdf·语音识别
踩着两条虫2 小时前
AI驱动的Vue3应用开发平台深入探究(十八):扩展与定制之集成第三方库
vue.js·人工智能·低代码·重构·架构
饼干哥哥2 小时前
聊了50个AI出海的市场团队,我总结了达人营销的7宗罪
前端
qq_427506082 小时前
vscode使用kimi code的简单经验分享
前端·vscode·ai编程
恋猫de小郭2 小时前
Claude Code 源码里有意思设定:伪造、投毒、卧底、封号
前端·人工智能·ai编程
Blurpath住宅代理3 小时前
网页抓取(Web Scraping)完整技术指南:从原理到实战
前端