logo

公共Hooks封装之文字溢出提示useEllipsisPopper

Sep 12, 2023 · 12 min

写在前面#

对于经常需要开发企业管理后台的前端开发来说,一定会遇到表格内显示字段省略后提示浮窗的需求场景,当然成熟的 UI 框架也已经解决了这一需求场景。但是对于整个项目或系统来说,类似的场景如何更好的复用,以及目前实际使用的 UI 框架可能不符合需求,因此结合实际业务封装了这个Hooks和对应扩展的业务组件。

基于个人项目环境进行封装的Hooks,仅以本文介绍封装Hooks思想心得,故相关代码可能不适用他人

项目环境#

Vue3.x + Ant Design Vue3.x + Vite4.x

业务场景分析#

图文内容仅供参考,仅提供文章内所需思考对应的图例

现有tooltip场景

在以上图片中,是管理后台系统中常见的表格内容,因使用的是 Ant Design Vue 框架,根据官方的文档中所示: Column 的API ellipsis 超出宽度自动省略,不支持和排序筛选一起使用,,且表格布局将变成 tableLayout="fixed"。 实际使用的代码:

[
  {
      title: '所属角色',
      key: 'role',
      width: 100,
    },
    {
      title: '所在部门',
      key: 'department',
      width: 160,
      ellipsis: true
    }
]

从上图中则暴露了一个问题,那就是由于 column 作为“配置项”传入表格组件,对于字数可能较长的字段,配置 ellipsis: true 后,无论文本内容是否超出表格列的宽度,都会渲染出 tooltip,从体验方面和性能方面来说,都未必好,渲染了一些“无意义”的DOM。

同样的,在中后台管理系统中,因为业务考虑或 UI 界面设计等等原因,会出现部分显示区域需要显示可能过长的字段内容,而根据技术选型配套的 Ant Design Vue提供了 tooltip 组件依然有上述问题。

Element Plus 如何做的?#

作为前端流行的UI框架之一,ElementUI Plus的表格内容,对于上述场景是怎么做的,我们可以从其文档中找到对应的内容~

Element-Plus Table属性

在上图中,发现 Element Plus 确实对于表格场景,解决了字段根据是否超出再显示 tooltip 的问题。后根据上述配置进行 demo 验证也发现确实可用。 根据官方文档和仓库中的部分源码,发现一个第三方 js 库

Element-Plus 仓库中关于 @popper/core 的部分截图

Popper.js#

TOOLTIP & POPOVER POSITIONING ENGINE

从官方文档及搜索出来各种教程,不难理解,这是一个扩展性较好的 tooltips 提示类 JS 插件,大小仅为 3.5KB 左右,使用与配置也相当简单,基于 popper.js 封装的组件库也有不少,关于这部分的内容,不作为文章重点,且已经有很多介绍其原理和其他相关的优秀内容,在此,不作赘述~

在了解了这个是干嘛的之后,开始着手写项目中需要的Hooks, 使用 popper.js 主要用到 createPopper() 方法,其接受了 3 个参数:reference(需要弹框的按钮Element)、popper(tooltip内容HTMLElement)以及 options

options 内主要用到了 placement(方向) 和 modifiers,Hooks内用到了nameoffset,未考虑其他配置参数,更完善和更复杂的一些封装,可以查看Element PlusTippy.js 等优秀的组件(方法)库。

 const popperInstance = createPopper(parent, tooltipContent, {
    placement: options.placement ?? 'top',
    modifiers: [
      {
        name: 'offset',
        options: {
          offset: [0, 8],
        },
      },
    ],
  });

封装分解:判断逻辑之宽度计算#

在查看了Element Plus的文档及源码后,发现其仅在Table组件中有自动省略显示的配置。而对于其他场景,通常我们的做法都是使用tooltip组件,而这种方式并没有考虑实际内容有没有超出。不能做到动态决定是否显示 tooltip。

Hooks 内的做法则是根据 【 子元素的宽度 + 父元素的 padding > 父元素的宽度 ?展示 tooltip : 不展示】

下面内容是关于实现此 Hooks 的一些部分内容拆解

const getPadding = el => {
  const style = window.getComputedStyle(el, null);
  const paddingLeft = Number.parseInt(style.paddingLeft, 10) || 0;
  const paddingRight = Number.parseInt(style.paddingRight, 10) || 0;
  const paddingTop = Number.parseInt(style.paddingTop, 10) || 0;
  const paddingBottom = Number.parseInt(style.paddingBottom, 10) || 0;
  return {
    left: paddingLeft,
    right: paddingRight,
    top: paddingTop,
    bottom: paddingBottom,
  };
};

为什么需要获取父元素 Padding ?这里则是关于 BFC 的一些问题,

BFC

判断子元素什么时候需要隐藏并展示 tooltip,根据上图,当 Child container的宽度 + 黄色区域的 padding > Parent container的宽度后,生成 tooltip.

let range = document.createRange();
range.setStart(target, 0);
range.setEnd(target, target.childNodes.length);
const rangeWidth = range.getBoundingClientRect().width;
range.detach();
const { left, right } = getPadding(target);
const horizontalPadding = left + right;

document.createRange()用来创建一个Range对象,包含了startContainerendContainer,在这里我们使用 setStartsetEnd 来创建选择的 DOM 范围,用来拿到 rangeWidth以方便后面的比较计算。 在使用范围后,调用 detach() 方法,以便从创建范围的文档中分离出该范围。

关于这部分内容,以及具体的 CSSOM视图相关的知识,可以查看张鑫旭大佬的文章,文章地址在这:CSSOM视图模式(CSSOM View Module)相关整理

封装分解:创建 tooltipContent#

生成 tooltip 的前置条件判断好了,tooltip 的内容要显示什么,本 Hooks 利用的是鼠标移入时获取自定义属性 data-title并将其赋值为innerText,根据 popper.js的文档,创建tooltipContentarrowContent

const renderContent = (target, parent) => {
  const tooltipContent = document.createElement('div');
  const arrowContent = document.createElement('div');
  arrowContent.className = ['ellipsis-tooltip-arrow'].join(' ');
  arrowContent.setAttribute('data-popper-arrow', 'true');
  tooltipContent.innerText = target.dataset.title;
  tooltipContent.setAttribute('role', 'tooltip');
  tooltipContent.appendChild(arrowContent);
  tooltipContent.className = ['ellipsis-tooltip'].join(' ');
  parent.setAttribute('aria-describedby', 'tooltip');
  parent.appendChild(tooltipContent);
  return {
    tooltipContent,
  };
};

同样的,在鼠标移出时,销毁 popperInstance、移除鼠标离开的监听事件。

  popperInstance.destroy();
  parent.removeChild(tooltipContent);
  parent.removeAttribute('aria-describedby');
  target.removeListener('mouseleave', removePopper);

封装分解:EllipsisPopper.vue 组件#

<template>
  <div class="ellipsis" :data-title="text" @mouseenter="handleCellMouseEnter">
    <span>{{ text }}</span>
  </div>
</template>

<script setup>
  import { useEllipsisPopper } from '@/hooks';

  defineProps({
    text: {
      type: String,
      required: true,
    },
  });

  const { handleCellMouseEnter } = useEllipsisPopper({ placement: 'auto' });
</script>

因考虑将管理系统中,除表格之外的其他渲染内容,也统一使用动态展示 tooltip,搭配 Hooks 使用,封装 EllipsisPopper 组件。

至此,便理清了Hooks内需要的内容,另外,Hooks内仅考虑了单行文本溢出隐藏展示 tooltip,对于多行文本溢出隐藏后展示 tooltip 的需求并未考虑,相对应的,也没有实现如 Element Plus更复杂的配置,Hooks 本身结合项目实际需求而言,未做更复杂的拓展。

最后,贴一下使用 EllipsisPopper组件和useEllipsisPopper.js后,文章初始的表格变化吧~

popper-05

因实际项目需要兼容生态应用(钉钉、飞书)等,需要根据对应开发平台展示部分企业架构相关的字段,文章所示的EllipsisPopper组件仅便于理解,和实际业务组件脱敏处理提取的内容,如果不需要兼容生态应用,则可以直接给父元素(即定款展示区域)自定义属性data-title,并加上 @mouseenter="handleCellMouseEnter"

最后,贴一下完整代码~

useEllipsisPopper.js完整代码#

import { createPopper } from '@popperjs/core';

const getPadding = el => {
  const style = window.getComputedStyle(el, null);
  const paddingLeft = Number.parseInt(style.paddingLeft, 10) || 0;
  const paddingRight = Number.parseInt(style.paddingRight, 10) || 0;
  const paddingTop = Number.parseInt(style.paddingTop, 10) || 0;
  const paddingBottom = Number.parseInt(style.paddingBottom, 10) || 0;
  return {
    left: paddingLeft,
    right: paddingRight,
    top: paddingTop,
    bottom: paddingBottom,
  };
};

const renderContent = (target, parent) => {
  const tooltipContent = document.createElement('div');
  const arrowContent = document.createElement('div');
  arrowContent.className = ['ellipsis-tooltip-arrow'].join(' ');
  arrowContent.setAttribute('data-popper-arrow', 'true');
  tooltipContent.innerText = target.dataset.title;
  tooltipContent.setAttribute('role', 'tooltip');
  tooltipContent.appendChild(arrowContent);
  tooltipContent.className = ['ellipsis-tooltip'].join(' ');
  parent.setAttribute('aria-describedby', 'tooltip');
  parent.appendChild(tooltipContent);
  return {
    tooltipContent,
  };
};

export function useEllipsisPopper(options = {}) {
  const handleCellMouseEnter = event => {
    const target = event.target;
    const parent = target.parentNode;
    let range = document.createRange();
    range.setStart(target, 0);
    range.setEnd(target, target.childNodes.length);
    const rangeWidth = range.getBoundingClientRect().width;
    range.detach();
    const { left, right } = getPadding(target);
    const horizontalPadding = left + right;
    if (Math.floor(rangeWidth + horizontalPadding) > target.clientWidth) {
      const { tooltipContent } = renderContent(target, parent);
      const popperInstance = createPopper(parent, tooltipContent, {
        placement: options.placement ?? 'top',
        modifiers: [
          {
            name: 'offset',
            options: {
              offset: [0, 8],
            },
          },
        ],
      });

      const removePopper = () => {
        popperInstance.destroy();
        parent.removeChild(tooltipContent);
        parent.removeAttribute('aria-describedby');
        target.removeListener('mouseleave', removePopper);
      };

      target.addEventListener('mouseleave', removePopper);
    }
  };

  return {
    handleCellMouseEnter,
  };
}

需要额外补充的样式至项目内#

.ellipsis-tooltip {
  z-index: 10;
  display: inline-block;
  background: #333333;
  color: #ffffff;
  padding: 5px 10px;
  font-size: 13px;
  border-radius: 4px;
}

.ellipsis-tooltip-arrow,
.ellipsis-tooltip-arrow::before {
  position: absolute;
  width: 6px;
  height: 6px;
  background: inherit;
}

.ellipsis-tooltip-arrow {
  visibility: hidden;
}

.ellipsis-tooltip-arrow::before {
  visibility: visible;
  content: '';
  transform: rotate(45deg);
}

.ellipsis-tooltip[data-popper-placement^='top'] > .ellipsis-tooltip-arrow {
  bottom: -3px;
}

.ellipsis-tooltip[data-popper-placement^='bottom'] > .ellipsis-tooltip-arrow {
  top: -3px;
}

.ellipsis-tooltip[data-popper-placement^='left'] > .ellipsis-tooltip-arrow {
  right: -3px;
}

.ellipsis-tooltip[data-popper-placement^='right'] > .ellipsis-tooltip-arrow {
  left: -3px;
}

参考链接#

logo
> cd ..