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

发布时间 2024-01-04 14:56:04作者: 柯基与佩奇

项目环境

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

业务场景分析

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

popper-01.gif

在以上图片中,是管理后台系统中常见的表格内容,因使用的是 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 库

popper-03.png

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 的一些问题,

popper-04.png 判断子元素什么时候需要隐藏并展示 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.gif

Tips 因实际项目需要兼容生态应用(钉钉、飞书)等,需要根据对应开发平台展示部分企业架构相关的字段,文章所示的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;
}

参考链接