前言
首先为什么不直接使用css的hover?
因为有多个地方都有这个,每个元素都包裹一个弹窗可以是可以,但是dom就堆积太多了。而且若父元素设置overflow:hidden怎么办?
因此还是决定写一个hover显示弹窗的组件,咋一看,写这个不是简简单单?然而写完了发现有个严重问题。鼠标滚轮滚动过快的时候不会触发鼠标移入事件,无论是mousenter还是pointerenter,导致要么弹窗出不来,要么弹窗位置错误的情况,严重影响体验!没办法查了半天也没查到怎么处理,最后只能优化下用户体验了,比如鼠标滚动的时候隐藏弹窗,鼠标在目标元素move的时候重新矫正弹窗位置。
本来是定位的hover显示弹窗的组件,后面没办法又需要支持click,然后就抽空重新更新了下这篇博客。。。
注意:
1、未考虑超出浏览器显示范围的边界情况,自行调整代码。
2、目前效果是在目标元素右侧垂直居中显示,其他位置自行调整代码。
3、支持设置水平、垂直方向的偏移量。
4、支持 pointerenter | click
两种触发弹窗的方式。
特别说明:对于click方式,需要额外参数【类或id选择器或者元素节点名称,eg: 类:.app | id: #app | 元素节点名称 body】。因为目标元素有子元素时,点击子元素也会触发click,那么e.target就变成了子元素,导致定位也是相对于子元素定位的,而不是目标元素!因此内部写了个方法根据第二个参数循环查找节点的父节点,直到找到我们想要的目标元素。
一、效果如下:
image.png二、vue版本为 "vue": "^3.3.0",
三、文件目录:
image.png四、源码为:
hoverTip/index.vue
<!--
author:yangfeng
date:2023/09/11
鼠标hover或click显示弹窗:使用时目标元素需要绑定 pointerenter | click 事件为下面导出的 showTip 事件
支持:pointerenter:鼠标指针移入目标元素显示当前弹窗
click:点击显示目标元素当前弹窗
可设置弹窗偏移量
注意:trigger为click时,showTip第二个参数必传,防止目标元素包含子元素,导致弹窗是相对子元素定位的而不是目标元素定位。【类或id选择器或者元素节点名称,eg: 类:.app | id: #app | 元素节点名称 body】
-->
<template>
<div class="hoverTip-box" ref="refDom" tabindex="-1">
<slot>
<div v-html="title"></div>
</slot>
</div>
</template>
<script>
export default {
name: 'hoverTip',
}
</script>
<script setup>
import { ref, onUnmounted, onMounted, nextTick } from 'vue'
import { findNodeFromCurrent } from './index'
const props = defineProps({
title: {
type: String,
default: '默认内容'
},
info: {
type: String,
default: ''
},
trigger: { // 触发方式 pointerenter:鼠标指针移入 | click:点击 【注意:对应目标元素触发方式也要改】
type: String,
default: 'pointerenter',
validator(value) {
// The value must match one of these strings
return ['pointerenter', 'click'].includes(value)
}
},
offsetX: { // 弹窗显示水平偏移量,单位px
type: Number,
default: 0
},
offsetY: { // 弹窗显示垂直偏移量,单位px
type: Number,
default: 0
}
})
const emit = defineEmits([
'clickEnd' // trigger click 特有事件: 点击事件结束,关闭弹窗【可在调用组件中消除点击的某些副作用】,比如,失去焦点的时候 - 鼠标事件因为是自动触发的,不用额外判断
])
const refDom = ref(null)
// 转为数字
const toNumber = (val) => {
if (Number(val).toString() === 'NaN') return 0
return Number(val)
}
const getOffset = () => {
return {
x: toNumber(props.offsetX),
y: toNumber(props.offsetY)
}
}
let addMoveEvented = false // 是否已经绑定鼠标移动事件
let target = null // 鼠标移入的目标dom
let focusIn = false // 是否focus弹窗
/**
* 关闭弹窗,消除副作用
* @param {*} clear 是否清除其他事件
*/
const hideTip = (clear = true) => {
// console.log('hideTip', target, clear)
refDom.value.style.display = 'none'
if (clear) {
addMoveEvented = false
target && target.removeEventListener('pointermove', pointerenterHandle)
target = null
focusIn = false
}
}
// 计算弹窗位置的核心方法
const calCore = (e, eventType) => {
if (!target) return hideTip()
// console.log('pointerenterHandle',target, eventType)
// e.preventDefault && e.preventDefault()
// target.setPointerCapture(e.pointerId);
let rect = target.getBoundingClientRect()
// console.log(target, rect, 'mouseenter')
// 计算位置
let offset = getOffset()
refDom.value.style.display = 'flex'
refDom.value.style.left = target.offsetWidth + rect.x + offset.x + 'px'
refDom.value.style.top = (target.offsetHeight / 2 + rect.y) - refDom.value.offsetHeight / 2 + offset.y + 'px' // 目标中心位置 - 弹窗一半高度
}
// 弹窗显示并设置弹窗位置
let pointerenterHandle = (e, eventType) => {
if (!target) return hideTip()
calCore(e, eventType)
eventConfig[props.trigger].bindEvent()
}
// 事件配置
const eventConfig = {
// 指针移入目标触发
'pointerenter': {
// 初始化
init: () => { },
bindEvent: () => {
// 离开目标隐藏弹窗
target.onpointerleave = function () {
hideTip()
}
target.onpointercancel = function () {
hideTip()
}
},
/**
* 显示弹窗
* @param {*} e 事件e
*/
showTip: (e) => {
target = e.target // 移入目标
if (!target) return hideTip()
// if (targetClass) { // 指定了事件触发目标的 class
// target = findNodeFromCurrent(target, targetClass, true)
// }
pointerenterHandle(e, 'pointerenter')
// 目标元素绑定move事件,优化鼠标滚轮和鼠标移入移出事件冲突导致的影响
if (!addMoveEvented) {
addMoveEvented = true
target.addEventListener('pointermove', pointerenterHandle)
}
}
},
// 点击目标元素触发
'click': {
// 初始化
init: () => {
refDom.value.onfocus = () => {
focusIn = true
}
refDom.value.onblur = () => {
focusIn = false
hideTip()
emit('clickEnd')
}
},
bindEvent: () => {
// 具有tabindex属性的标签就可以正常使用onfocus()和onblur()事件了。
let tabIndex = '-1'
target.tabIndex = tabIndex
target.setAttribute('tabIndex', tabIndex);
target.tabindex = tabIndex
target.setAttribute('tabindex', tabIndex);
// 先focus才能触发onblur
target.focus();
target.style.outline = 'none' // 去掉默认的outline样式,因为目前场景目标元素是用的div
// 失去焦点
target.onblur = function () {
// 若点击的弹窗,不关闭
setTimeout(() => {
if(!focusIn){
hideTip()
emit('clickEnd')
}
})
}
},
/**
* 显示弹窗
* @param {*} e 事件e
* @param {*} targetClass 类或id选择器或者元素节点名称,eg: 类:.app | id: #app | 元素节点名称 body - 点击显示弹窗模式下 - 必传,因为若内部有子元素,点击时target是指向的子元素
*/
showTip: (e, targetClass) => {
target = e.target // 移入目标
if (!target) return hideTip()
if (targetClass) { // 指定了事件触发目标的 class
target = findNodeFromCurrent(target, targetClass, true)
}
pointerenterHandle(e, 'click')
}
}
}
/**
* 显示弹窗
* @param {*} e 事件e
* @param {*} targetClass 类或id选择器或者元素节点名称,eg: 类:.app | id: #app | 元素节点名称 body
*/
const showTip = (e, targetClass) => {
eventConfig[props.trigger].showTip(e, targetClass)
}
// #region 鼠标滚动事件 - 因为鼠标滚轮和鼠标移入移出事件冲突 - 这里在滚动时隐藏弹窗
const wheelHandle = function (e) {
hideTip(false)
}
const listenMouseWheel = () => {
if (window.addEventListener) {
window.addEventListener("wheel", wheelHandle)
} else {
window.attachEvent("onmousewheel", wheelHandle)
}
}
const removeMouseWheel = () => {
if (window.removeEventListener) {
window.removeEventListener("wheel", wheelHandle)
} else {
window.dettachEvent("onmousewheel", wheelHandle)
}
}
// #endregion 鼠标滚动事件
onMounted(() => {
listenMouseWheel()
eventConfig[props.trigger].init()
})
onUnmounted(() => {
removeMouseWheel()
})
defineExpose({
showTip, // 鼠标移入或点击显示弹窗
hideTip
})
</script>
<style lang="scss" scoped>
$fontColor: #333333;
// 鼠标hover详情
.hoverTip-box {
position: fixed;
width: 419px;
height: 154px;
background: #FFFFFF;
color: $fontColor;
// display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0px 0px 16px 0px rgba(1, 10, 21, 0.09);
border-radius: 4px;
z-index: 7;
transition: top ease 0.2s;
// 初始样式
display: none;
left: 0;
top: 0;
}
</style>
hoverTip/index.js
import { ref } from 'vue'
/**
* js查找指定节点【包含|不包含】往上的节点,可根据类选择器(如:.app)、id选择器(如:#app)、元素节点名称如(h1)进行查找
* 换句话就是,查找当前节点的指定父节点,可以选择是否是包含当前节点
* @param ele 子节点
* @param flag 父节点类或id选择器或者元素节点名称,eg: 类:.app | id: #app | 元素节点名称 body
* @param includeCurrent 是否包含当前节点,默认false,查找的父节点
* @returns {HTMLElement | null} 指定的第一个父节点
*/
export function findNodeFromCurrent(ele, flag, includeCurrent = false) {
if (!flag || flag === 'body') {
// 默认body
flag = 'body'
return document.getElementsByTagName(flag)[0]
}
if (!ele) return null
// 判断是否是这个节点
let judgeFn = (_node) => {
if (!_node) return false
if (flag.startsWith('.')) {
// 类
let reg = new RegExp(`^\.`, 'i')
let classNameStr = flag.replace(reg, '')
return classNameStr === _node.className || ~_node.className.indexOf(classNameStr)
} else if (flag.startsWith('#')) {
// id
let reg = new RegExp(`^\#`, 'i')
return flag.replace(reg, '') === _node.id
} else {
// 节点名
return flag === _node.nodeName.toLowerCase()
}
}
let parent = null
if (includeCurrent) {
// 包含当前节点 - 从当前节点开始
parent = ele
} else {
// 从父节点开始
parent = ele.parentNode
}
while (parent && !judgeFn(parent) && parent.nodeName !== 'BODY' && parent.nodeName !== 'HTML') {
parent = parent.parentNode
}
return !parent || parent.nodeName === 'BODY' || parent.nodeName === 'HTML' ? null : parent
}
export default function useHoverTip() {
const hoverTipDomRef = ref(null)
/**
* 显示弹窗
* @param {*} e 事件e
* @param {*} targetClass 类或id选择器或者元素节点名称,eg: 类:.app | id: #app | 元素节点名称 body
*/
const hoverTipPointerenter = (e, targetClass) => {
hoverTipDomRef.value.showTip(e, targetClass)
}
/**
* 关闭弹窗,消除副作用
* @param {*} clear 是否清除其他事件
*/
const hideTip = (clear = true)=>{
hoverTipDomRef.value.hideTip(clear)
}
return {
hoverTipDomRef,
hoverTipPointerenter,
hideTip
}
}
五、使用方式
image.pngimage.png
其实就是要调用hoverTip组件提供的showTip 方法。
六、顺带说一句
js动态设置tabIndex后绑定了onblur事件发现第一次未触发,后面想到要先focus才会触发onblur!!!
若对你有帮助,请点个赞吧,若能打赏不胜感激,谢谢支持!
本文地址:https://www.jianshu.com/p/68208de9c5c3?v=1694144344201,转载请注明出处,谢谢。
网友评论