v-tooltip自定义指令封装

v-tooltip自定义指令封装

前言

前端开发中,经常遇到文字太长盒子装不下的问题,我们一般都是超出以省略号展示,鼠标悬浮展示提示框,提示框中展示全部的文字;但是如果不超出不展示省略号的时候,鼠标悬浮不展示提示框是一个不太好处理的问题。今天我来分享一个vue中的自定义指令封装代码,可以很好地解决这个问题。

代码

javascript 复制代码
/*
 * @Description: 文字溢出提示指令,宽度不足时显示省略号并在悬浮时展示完整内容
 * @FilePath: /src/directives/tooltip/index.ts
 *
 * 用法:
 *   v-tooltip                                  取元素自身文本作为提示内容
 *   v-tooltip="content"                        指定提示内容
 *   v-tooltip="{ content, placement, effect, lines, disabled, maxWidth }"
 *   v-tooltip.bottom="content"                 通过修饰符指定弹出方向
 */

import { DirectiveOptions } from 'vue';

type Placement = 'top' | 'bottom' | 'left' | 'right';

interface ITooltipOptions {
  content?: string;
  placement?: Placement;
  effect?: 'dark' | 'light';
  lines?: number;
  disabled?: boolean;
  maxWidth?: number;
}

interface ITooltipEl extends HTMLElement {
  __tooltip__?: {
    options: ITooltipOptions;
    onEnter: () => void;
    onLeave: () => void;
  };
}

const POPPER_CLASS = 'v-tooltip-popper';
const STYLE_ID = 'v-tooltip-style';
const PLACEMENTS: Placement[] = ['top', 'bottom', 'left', 'right'];
// 提示框与元素的间距、提示框与视口边缘的最小留白
const GAP = 10;
const EDGE = 8;

let popper: HTMLElement | null = null;
let arrow: HTMLElement | null = null;
let activeEl: HTMLElement | null = null;

function injectStyle() {
  if (document.getElementById(STYLE_ID)) return;
  const style = document.createElement('style');
  style.id = STYLE_ID;
  style.textContent = `
.${POPPER_CLASS} {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 9999;
  white-space: normal;
  word-break: break-all;
  line-height: 1.4;
  opacity: 0;
  transition: opacity 0.2s;
  pointer-events: none;
}
.${POPPER_CLASS}.is-visible {
  opacity: 1;
}`;
  document.head.appendChild(style);
}

function getPopper(): HTMLElement {
  if (!popper) {
    injectStyle();
    popper = document.createElement('div');
    popper.className = `el-tooltip__popper ${POPPER_CLASS}`;
    arrow = document.createElement('div');
    arrow.className = 'popper__arrow';
    popper.appendChild(arrow);
    document.body.appendChild(popper);
  }
  return popper;
}

function normalizeOptions(value: any, modifiers: { [key: string]: boolean }): ITooltipOptions {
  const options: ITooltipOptions =
    value && typeof value === 'object'
      ? { ...value }
      : { content: value === undefined || value === null ? '' : String(value) };

  const modifierPlacement = PLACEMENTS.find(item => modifiers[item]);
  if (modifierPlacement) {
    options.placement = modifierPlacement;
  }
  return options;
}

// 设置省略号样式,单行省略或多行截断
function applyEllipsis(el: HTMLElement, lines: number) {
  if (lines > 1) {
    el.style.display = '-webkit-box';
    el.style.webkitBoxOrient = 'vertical';
    el.style.webkitLineClamp = String(lines);
    el.style.whiteSpace = 'normal';
    el.style.overflow = 'hidden';
  } else {
    el.style.whiteSpace = 'nowrap';
    el.style.overflow = 'hidden';
    el.style.textOverflow = 'ellipsis';
  }
}

// 内容是否超出容器,未超出时不显示提示框
function isOverflow(el: HTMLElement, lines: number): boolean {
  return lines > 1 ? el.scrollHeight - el.clientHeight > 1 : el.scrollWidth - el.clientWidth > 1;
}

function getContent(el: HTMLElement, options: ITooltipOptions): string {
  if (options.content) return options.content;
  return (el.textContent || '').trim();
}

function updatePosition(el: HTMLElement, placement: Placement) {
  const box = getPopper();
  const rect = el.getBoundingClientRect();
  const width = box.offsetWidth;
  const height = box.offsetHeight;
  const { clientWidth: vw, clientHeight: vh } = document.documentElement;

  // 空间不足时自动翻转方向
  let finalPlacement = placement;
  if (placement === 'top' && rect.top - height - GAP < EDGE) finalPlacement = 'bottom';
  else if (placement === 'bottom' && rect.bottom + height + GAP > vh - EDGE) finalPlacement = 'top';
  else if (placement === 'left' && rect.left - width - GAP < EDGE) finalPlacement = 'right';
  else if (placement === 'right' && rect.right + width + GAP > vw - EDGE) finalPlacement = 'left';

  let left = 0;
  let top = 0;
  if (finalPlacement === 'top' || finalPlacement === 'bottom') {
    left = rect.left + rect.width / 2 - width / 2;
    top = finalPlacement === 'top' ? rect.top - height - GAP : rect.bottom + GAP;
  } else {
    left = finalPlacement === 'left' ? rect.left - width - GAP : rect.right + GAP;
    top = rect.top + rect.height / 2 - height / 2;
  }

  left = Math.min(Math.max(left, EDGE), Math.max(vw - width - EDGE, EDGE));
  top = Math.min(Math.max(top, EDGE), Math.max(vh - height - EDGE, EDGE));

  box.setAttribute('x-placement', finalPlacement);
  box.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;

  // 箭头跟随元素中心
  if (arrow) {
    if (finalPlacement === 'top' || finalPlacement === 'bottom') {
      const offset = Math.min(Math.max(rect.left + rect.width / 2 - left, 12), Math.max(width - 12, 12));
      arrow.style.left = `${Math.round(offset)}px`;
      arrow.style.top = '';
    } else {
      const offset = Math.min(Math.max(rect.top + rect.height / 2 - top, 12), Math.max(height - 12, 12));
      arrow.style.top = `${Math.round(offset)}px`;
      arrow.style.left = '';
    }
  }
}

function hide() {
  activeEl = null;
  if (!popper) return;
  popper.classList.remove('is-visible');
  popper.style.display = 'none';
  window.removeEventListener('scroll', hide, true);
  window.removeEventListener('resize', hide);
}

function show(el: HTMLElement, options: ITooltipOptions) {
  const lines = Number(options.lines) || 1;
  if (options.disabled || !isOverflow(el, lines)) return;

  const content = getContent(el, options);
  if (!content) return;

  const box = getPopper();
  box.className = `el-tooltip__popper ${POPPER_CLASS} ${options.effect === 'light' ? 'is-light' : 'is-dark'}`;
  box.style.maxWidth = `${Number(options.maxWidth) || 400}px`;
  box.style.display = 'block';
  // 文本节点放在箭头之前,避免覆盖箭头元素
  box.textContent = content;
  if (arrow) box.appendChild(arrow);

  activeEl = el;
  updatePosition(el, options.placement || 'top');
  // 定位完成后再淡入,避免出现位移
  window.requestAnimationFrame(() => {
    if (activeEl === el && popper) popper.classList.add('is-visible');
  });

  window.addEventListener('scroll', hide, true);
  window.addEventListener('resize', hide);
}

export const tooltip: DirectiveOptions = {
  inserted(el, binding) {
    const target = el as ITooltipEl;
    const options = normalizeOptions(binding.value, binding.modifiers);
    applyEllipsis(target, Number(options.lines) || 1);

    const onEnter = () => show(target, target.__tooltip__ ? target.__tooltip__.options : options);
    const onLeave = () => {
      if (activeEl === target) hide();
    };

    target.__tooltip__ = { options, onEnter, onLeave };
    target.addEventListener('mouseenter', onEnter);
    target.addEventListener('mouseleave', onLeave);
  },

  componentUpdated(el, binding) {
    const target = el as ITooltipEl;
    if (!target.__tooltip__) return;
    const options = normalizeOptions(binding.value, binding.modifiers);
    target.__tooltip__.options = options;
    applyEllipsis(target, Number(options.lines) || 1);
    if (activeEl === target) hide();
  },

  unbind(el) {
    const target = el as ITooltipEl;
    if (activeEl === target) hide();
    if (target.__tooltip__) {
      target.removeEventListener('mouseenter', target.__tooltip__.onEnter);
      target.removeEventListener('mouseleave', target.__tooltip__.onLeave);
      delete target.__tooltip__;
    }
  }
};

用法

  • v-tooltip 取元素自身文本作为提示内容
  • v-tooltip="content" 指定提示内容
  • v-tooltip="{ content, placement, effect, lines, disabled, maxWidth }"
  • v-tooltip.bottom="content" 通过修饰符指定弹出方向

结语

关注我不迷路

不定期发表前端开发技巧类的文章

相关推荐
因_崔斯汀1 小时前
Three.js 3D 热力云图效果实现
前端·three.js
爱勇宝2 小时前
人到了一定年纪,才会看懂这些人性真相
前端·后端
梓䈑2 小时前
【用 Vibe Coding 实现的 C++17 在线判题系统】前端开发 + Web 自动化测试
前端·c++·ai编程
程序员黑豆2 小时前
鸿蒙应用开发 @BuilderParam 使用教程:实现灵活的 UI 插槽
前端·harmonyos
万少3 小时前
用 TraeWork 给小孩做一个家庭工作台
前端·人工智能·后端
孙启超3 小时前
【AI应用开发】ReAct 原理是什么?和普通直接提问 LLM 差别在哪?
前端·人工智能·llm·agent·react·rag·ai应用开发
其美杰布-富贵-李3 小时前
第 10 篇:灯光与阴影
javascript·three.js
SynthWriter3 小时前
下班前接到急活?我用 TRAE Work 20 分钟搞定了一套完整页面原型
前端
minimoon_jojo3 小时前
Antd Table虚拟滚动详解
前端