仿 ElementPlus 组件库(四)—— Tooltip 组件实现

我们在模仿 ElementPlus 组件库的道路上迈出了坚实的步伐,成功探索了 Icon 组件的实现。今天,我们将继续深入,聚焦于另一个在提升用户体验方面发挥关键作用的组件 ------Tooltip 组件,即提示框组件,以其简洁高效的信息传达方式,在各类用户界面中广泛应用,是构建易用且交互性强的应用不可或缺的部分。

一、什么是 Tooltip 组件

Tooltip 组件是一种交互元素,常用于展示鼠标悬停时的提示信息。这些信息可以是对元素功能的解释、操作说明或者补充细节,帮助用户更好地理解和使用界面元素,减少误操作的可能性。

二、实现 Tooltip 组件

(一)安装Popper.js

Popper.js 是一款用于处理元素定位和显示的 JavaScript 库,在实现 Tooltip 组件等需要精确位置定位和交互的场景中发挥着重要作用.

  • 自动计算位置:根据宿主元素(触发按钮)和视口边界,动态调整 Tooltip 的显示位置(如顶部、底部、左侧、右侧);
  • 处理边界溢出:避免 Tooltip 超出屏幕,自动翻转方向(如底部无空间时切换到顶部);
  • 支持动画与交互:配合 Vue 的过渡动画,实现流畅的显示 / 隐藏效果;
  • 轻量且灵活:体积仅~30KB(压缩后),支持 Vue、React 等框架,与 ElementPlus 的设计理念高度契合。
css 复制代码
npm i @popperjs/core --save

Vue.js中引入

Vue.js 复制代码
import { createPopper } from '@popperjs/core'

const overlayNode = ref<HTMLElement>
const triggerNode = ref<HTMLElement>

(二)组件目录

目录 复制代码
components
├── Tooltip
    ├── Tooltip.vue
    ├── types.ts
    ├── style.css    

(三)初步实现Tooltip组件基本功能

实现基本的提示框功能,用户可以通过点击触发元素来显示或隐藏提示框,并且可以通过 props 自定义提示框的内容、触发方式和显示位置。

types.ts 复制代码
import type { Placement } from '@popperjs/core'
export interface TooltipProps {
  content?: string
  trigger?: 'hover' | 'click'
  placement?: Placement
}

export interface TooltipEmits {
  (e: 'visible-change', value: boolean): void
}
Tooltip.vue 复制代码
<template>
  <div class="yl-tooltip">
    <div class="yl-tooltip__trigger" ref="triggerNode" @click="togglePopper">
      <slot></slot>
    </div>
    <div v-if="isOpen" class="yl-tooltip__popper" ref="popperNode">
      <slot name="content">
        {{ content }}
      </slot>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'
import { createPopper } from '@popperjs/core'
import type { Instance } from '@popperjs/core'
import type { TooltipProps, TooltipEmits } from './types'
const props = withDefaults(defineProps<TooltipProps>(), {
  placement: 'bottom',
})
const emits = defineEmits<TooltipEmits>()
const isOpen = ref(false)
const popperNode = ref<HTMLElement>()
const triggerNode = ref<HTMLElement>()
let popperInstance: null | Instance = null
const togglePopper = () => {
  isOpen.value = !isOpen.value
  emits('visible-change', isOpen.value)
}
watch(
  isOpen,
  (newValue) => {
    if (newValue) {
      if (triggerNode.value && popperNode.value) {
        popperInstance = createPopper(triggerNode.value, popperNode.value,{placement:props.placement})
      } else {
        popperInstance?.destroy()
      }
    }
  },
  { flush: 'post' },
)
</script>
App.vue 复制代码
import Tooltip from './components/Tooltip/Tooltip.vue'

(三)对Tooltip组件添加动态事件支持

  • 通过动态绑定事件,组件可以根据 props.trigger 的值灵活地改变提示框的触发方式。当 trigger 属性为 'hover' 时,用户鼠标悬停在触发元素上会打开提示框,鼠标离开外部元素时会关闭提示框;当 trigger 属性为 'click' 时,用户点击触发元素可以切换提示框的显示状态。
  • 同时,使用 watch 函数监听 trigger 属性的变化,当该属性值改变时,会自动更新事件绑定,确保组件能根据新的触发方式正常工作。
Tooltip.vue 复制代码
<template>
  <div class="yl-tooltip" v-on="outerEvents">
    <div class="yl-tooltip__trigger" ref="triggerNode" v-on="events">
      <slot></slot>
    </div>
    //...
</template>
//...
const props = withDefaults(defineProps<TooltipProps>(), {
  placement: 'bottom',
  trigger: 'hover',
})
let outerEvents: Record<string, any> = reactive({})
const togglePopper = () => {
  isOpen.value = !isOpen.value
  emits('visible-change', isOpen.value)
}
const open = () => {
  isOpen.value = true
  emits('visible-change', true)
}
const close = () => {
  isOpen.value = false
  emits('visible-change', false)
}
const attachEvents = () => {
  if (props.trigger === 'hover') {
    events['mouseenter'] = open
    outerEvents['mouseleave'] = close
  } else if (props.trigger === 'click') {
    events['click'] = togglePopper
  }
}
attachEvents()
watch(
  () => props.trigger,
  (newTrigger, oldTrigger) => {
    if (newTrigger != oldTrigger) {
      //清空事件
      events = {}
      outerEvents = {}
      attachEvents()
    }
  },
)

(四)对Tooltip组件添加外侧点击关闭功能

  • 通过封装 useClickOutside 自定义钩子函数,实现对指定元素外部点击事件的监听。
  • Tooltip 组件中,使用该钩子监听 popperContainerNode 所引用的元素外部的点击事件。
  • 当用户点击该元素外部,并且 Tooltip 组件的触发方式为 'click' 且提示框处于打开状态时,会自动调用 close 方法关闭提示框。
目录 复制代码
src
├── hooks
    ├── useClickOutside.ts  
useClickOutside.ts 复制代码
import { onMounted, onUnmounted } from 'vue'
import type { Ref } from 'vue'
const useClickOutside = (elementRef: Ref<undefined | HTMLElement>, callback: (e: MouseEvent) => void) => {
  const handler = (e: MouseEvent) => {
    if (elementRef.value && e.target) {
      if (!elementRef.value.contains(e.target as HTMLElement)) {
        callback(e)
      }
    }
  }
  onMounted(() => {
    document.addEventListener('click', handler)
  })
  onUnmounted(() => {
    document.removeEventListener('click', handler)
  })
}

export default useClickOutside
 
Tooltip.vue 复制代码
<template>
  <div class="yl-tooltip" ref="popperContainerNode" v-on="outerEvents">
    //...
import useClickOutside from '@/hooks/useClickOutside'

const popperContainerNode = ref<HTMLElement>()

useClickOutside(popperContainerNode, () => {
  if (props.trigger === 'click' && isOpen.value) {
    close()
  }
})

(五)对Tooltip组件添加手动打开关闭功能

  • TooltipProps 中新增 manual 属性,用户可以控制提示框是否进入手动模式。
  • 在手动模式下(manualtrue),提示框不会响应默认的触发事件(如鼠标悬停或点击),并且点击提示框外部也不会关闭提示框,用户需要通过调用组件暴露的 showhide 方法来手动控制提示框的显示与隐藏。
  • 在非手动模式下(manualfalse),提示框会按照之前的逻辑,根据 trigger 属性绑定相应的事件来自动显示和隐藏,同时点击提示框外部也会关闭提示框。
types.ts 复制代码
export interface TooltipProps {
  //...
  manual?: boolean;
}

export interface TooltipInstance {
  show: () => void;
  hide: () => void;
}
Tooltip.vue 复制代码
import type { TooltipProps, TooltipEmits,TooltipInstance } from './types'
import { ref, watch, reactive,onUnmounted } from 'vue'

useClickOutside(popperContainerNode, () => {
  if (props.trigger === 'click' && isOpen.value && !props.manual) {
    close()
  }
})

if (!props.manual) {
  attachEvents()
}

watch(
  () => props.manual,
  (isManual) => {
    if (isManual) {
      events = {}
      outerEvents = {}
    } else {
      attachEvents()
    }
  },
)

onUnmounted(() => {
  popperInstance?.destroy()
})

defineExpose<TooltipInstance>({
  show: open,
  hide: close,
})
App.vue 复制代码
import type { TooltipInstance } from './components/Tooltip/types'
const tooltipRef= ref<TooltipInstance | null>(null)

(六)对Tooltip组件添加Popper参数

  • 原有的 Tooltip 组件基础上添加了对 Popper 参数的支持。
  • 通过在 TooltipProps 中新增 popperOptions 属性,用户可以传入自定义的 Popper 配置选项,以更精细地控制提示框的行为和样式。
  • Tooltip.vue 组件内部,使用计算属性 popperOptions 将用户传入的 popperOptions 与默认的 placements 属性合并,生成最终的 Popper 配置选项。
  • 当提示框显示时,使用合并后的配置选项创建 Popper 实例,从而实现对提示框定位和其他行为的自定义配置。
types.ts 复制代码
import type { Placement, Options } from '@popperjs/core'
export interface TooltipProps {
  //...
  popperOptions?: Partial<Options>;

}
Tooltip.vue 复制代码
import { ref, watch, reactive, onUnmounted,computed } from 'vue'

const popperOptions = computed(() => {
  return {
    placements: props.placement,
    ...props.popperOptions
  }
})

watch(
  isOpen,
  (newValue) => {
    if (newValue) {
      if (triggerNode.value && popperNode.value) {
        popperInstance = createPopper(triggerNode.value, popperNode.value, popperOptions.value)
      } else {
        popperInstance?.destroy()
      }
    }
  },
  { flush: 'post' },
)
App.vue 复制代码
import type { Options } from '@popperjs/core'

(七)对Tooltip组件添加动画效果

  • 在原有的 Tooltip 组件基础上添加了动画效果。
  • 通过在 TooltipProps 中新增 transition 属性,用户可以自定义提示框显示和隐藏时的过渡动画名称。
  • Tooltip.vue 组件中,使用 <Transition> 组件包裹提示框元素,并根据 transition 属性值动态应用过渡动画。
  • 同时,为 transition 属性设置了默认值 'fade',并在 style.css 文件中定义了 fade 过渡动画的样式,实现了提示框淡入淡出的效果,使 Tooltip 组件在显示和隐藏时更加平滑和美观。
types.ts 复制代码
export interface TooltipProps {
  //...
  transition?: string;
}
Tooltip.vue 复制代码
    <Transition :name="transition">
      <div v-if="isOpen" class="yl-tooltip__popper" ref="popperNode">
        <slot name="content">
          {{ content }}
        </slot>
      </div>
    </Transition>
    
const props = withDefaults(defineProps<TooltipProps>(), {
  //...
  transition:'fade'
})
style.css 复制代码
.yl-tooltip {
  .fade-enter-active,
  .fade-leave-active {
    transition: opacity var(--yl-transition-duration);
  }

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
}
style/index.css 复制代码
@import '../components/Tooltip/style.css';

(八)对Tooltip组件实现延时显示隐藏功能

  • 通过在 TooltipProps 中新增 openDelaycloseDelay 属性,用户可以分别设置提示框显示和隐藏的延迟时间。
  • 在组件内部,使用 debounce 函数对提示框的打开和关闭操作进行了防抖处理。
  • 当用户触发打开或关闭提示框的事件(如鼠标悬停、点击等)时,并不会立即执行打开或关闭操作,而是等待指定的延迟时间。
  • 如果在延迟时间内再次触发相同操作,会重新计算延迟时间,只有在延迟时间结束且没有再次触发时,才会真正执行打开或关闭提示框的操作。
  • 这种设计可以避免用户频繁触发操作时,提示框不必要的显示和隐藏,提升了用户体验,并且用户可以根据实际需求灵活调整延迟时间。
css 复制代码
npm install lodash-es --save
types.ts 复制代码
export interface TooltipProps {
//...
  openDelay?: number
  closeDelay?: number
}
Tooltip.vue 复制代码
import { debounce } from 'lodash-es'
let OpenTimes = 0
let CloseTimes = 0
const props = withDefaults(defineProps<TooltipProps>(), {
//...
  openDelay: 0,
  closeDelay: 0,
})

const open = () => {
  OpenTimes++
  console.log('open times', OpenTimes)
  isOpen.value = true
  emits('visible-change', true)
}
const close = () => {
  CloseTimes++
  console.log('close times', CloseTimes)
  isOpen.value = false
  emits('visible-change', false)
}
const openDebounce = debounce(open, props.openDelay)
const closeDebounce = debounce(close, props.closeDelay)

const openFinal = () => {
  closeDebounce.cancel()
  openDebounce()
}
const closeFinal = () => {
  openDebounce.cancel()
  closeDebounce()
}
const togglePopper = () => {
  if (isOpen.value) {
    closeFinal()
  } else {
    openFinal()
  }
}
useClickOutside(popperContainerNode, () => {
  if (props.trigger === 'click' && isOpen.value && !props.manual) {
    closeFinal()
  }
})
const attachEvents = () => {
  if (props.trigger === 'hover') {
    events['mouseenter'] = openFinal
    outerEvents['mouseleave'] = closeFinal
  } else if (props.trigger === 'click') {
    events['click'] = togglePopper
  }
}
defineExpose<TooltipInstance>({
  show: openFinal,
  hide: closeFinal,
})
style.css 复制代码
.yl-tooltip {
  --yl-popover-bg-color: var(--yl-bg-color-overlay);
  --yl-popover-font-size: var(--yl-font-size-base);
  --yl-popover-border-color: var(--yl-border-color);
  --yl-popover-padding: 12px;
  --yl-popover-border-radius: 4px;
  display: inline-block;
}

.yl-tooltip {
  .yl-tooltip__popper {
    background: var(--yl-popover-bg-color);
    border-radius: var(--yl-popover-border-radius);
    border: 1px solid var(--yl-popover-border-color);
    padding: var(--yl-popover-padding);
    color: var(--yl-text-color-regular);
    line-height: 1.4;
    text-align: justify;
    font-size: var(--yl-popover-font-size);
    box-shadow: var(--yl-box-shadow-light);
    word-break: break-all;
    box-sizing: border-box;
    z-index: 1000;
    #arrow,
    #arrow::before {
      position: absolute;
      width: 8px;
      height: 8px;
      box-sizing: border-box;
      background: var(--yl-popover-bg-color);
    }
    #arrow {
      visibility: hidden;
    }
    #arrow::before {
      visibility: visible;
      content: '';
      transform: rotate(45deg);
    }
    &[data-popper-placement^='top'] > #arrow {
      bottom: -5px;
    }

    &[data-popper-placement^='bottom'] > #arrow {
      top: -5px;
    }

    &[data-popper-placement^='left'] > #arrow {
      right: -5px;
    }

    &[data-popper-placement^='right'] > #arrow {
      left: -5px;
    }
    &[data-popper-placement^='top'] > #arrow::before {
      border-right: 1px solid var(--yl-popover-border-color);
      border-bottom: 1px solid var(--yl-popover-border-color);
    }
    &[data-popper-placement^='bottom'] > #arrow::before {
      border-left: 1px solid var(--yl-popover-border-color);
      border-top: 1px solid var(--yl-popover-border-color);
    }
    &[data-popper-placement^='left'] > #arrow::before {
      border-right: 1px solid var(--yl-popover-border-color);
      border-top: 1px solid var(--yl-popover-border-color);
    }
    &[data-popper-placement^='right'] > #arrow::before {
      border-left: 1px solid var(--yl-popover-border-color);
      border-bottom: 1px solid var(--yl-popover-border-color);
    }
  }

  .fade-enter-active,
  .fade-leave-active {
    transition: opacity var(--yl-transition-duration);
  }

  .fade-enter-from,
  .fade-leave-to {
    opacity: 0;
  }
}
Tooltip.vue 复制代码
<template>
//...
        <slot name="content">
          {{ content }}
        </slot>
        <div id="arrow" data-popper-arrow></div>
//...
</template>

const popperOptions = computed(() => {
  return {
    placements: props.placement,
    modifiers: [
      {
        name: 'offset',
        options: {
          offset: [0, 8],
        },
      },
    ],
    ...props.popperOptions,
  }
})
相关推荐
跟着汪老师学编程2 分钟前
24、web前端开发之CSS3(一)
前端·css·css3
beibeibeiooo8 分钟前
【CSS3】01-初始CSS + 引入 + 选择器 + div盒子 + 字体修饰
前端·css·css3
孤╮独的美15 分钟前
CSS3:深度解析与实战应用
前端·css·css3
一城烟雨_24 分钟前
Vue3 实现pdf预览
前端·vue.js·pdf
易xingxing28 分钟前
探索HTML5 Canvas:创造动态与交互性网页内容的强大工具
前端·html·html5
好_快37 分钟前
Lodash源码阅读-arrayPush
前端·javascript·源码阅读
好_快39 分钟前
Lodash源码阅读-equalByTag
前端·javascript·源码阅读
大土豆的bug记录6 小时前
鸿蒙进行视频上传,使用 request.uploadFile方法
开发语言·前端·华为·arkts·鸿蒙·arkui
maybe02096 小时前
前端表格数据导出Excel文件方法,列自适应宽度、增加合计、自定义文件名称
前端·javascript·excel·js·大前端
HBR666_6 小时前
菜单(路由)权限&按钮权限&路由进度条
前端·vue