我们在模仿 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
属性,用户可以控制提示框是否进入手动模式。 - 在手动模式下(
manual
为true
),提示框不会响应默认的触发事件(如鼠标悬停或点击),并且点击提示框外部也不会关闭提示框,用户需要通过调用组件暴露的show
和hide
方法来手动控制提示框的显示与隐藏。 - 在非手动模式下(
manual
为false
),提示框会按照之前的逻辑,根据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
中新增openDelay
和closeDelay
属性,用户可以分别设置提示框显示和隐藏的延迟时间。 - 在组件内部,使用
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,
}
})