這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 前言 日常開發中,我們經常遇到過tooltip這種需求。文字溢出、產品文案、描述說明等等,每次都需要寫一大串代碼,那麼有沒有一種簡單的方式呢,這回我們用指令來試試。 功能特性 支持tooltip樣式自定義 支持tooltip內容自定義 動 ...
這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助
前言
日常開發中,我們經常遇到過tooltip
這種需求。文字溢出、產品文案、描述說明等等,每次都需要寫一大串代碼,那麼有沒有一種簡單的方式呢,這回我們用指令來試試。
功能特性
- 支持
tooltip
樣式自定義 - 支持
tooltip
內容自定義 - 動態更新
tooltip
內容 - 文字省略自動出提示
- 支持彈窗位置自定義和偏移
功能實現
在vue3
中,指令也是擁有著對應的生命周期。
我們這裡需要使用的是 mounted
、updated
和unmounted
鉤子。
import { DirectiveBinding } from 'vue' export default { mounted(el: HTMLElement, binding: DirectiveBinding) { }, updated(el: HTMLElement, binding: DirectiveBinding) { }, unmounted(el: HTMLElement) { } }
在元素掛載完成之後,我們需要完成上述指令的功能。
什麼時候可用?
首先我們需要考慮的是tooltip
什麼時候可用?
- 元素是省略元素
- 手動開啟時,我們需要啟用
tooltip
,比如描述或者產品文案等等。
如果是省略元素,我們需要先判斷元素是否存在省略,一般通過這種方式判斷:
function isOverflow(el: SpecialHTMLElement) { if (el.scrollWidth > el.offsetWidth || el.scrollHeight > el.clientHeight) { return true } return false } // element plus 採用如下方式判斷,相容 firefox function isOverflow(el: SpecialHTMLElement){ const range = document.createRange() range.setStart(el, 0) range.setEnd(el, el.childNodes.length) const rangeWidth = range.getBoundingClientRect().width const padding = (Number.parseInt(getComputedStyle(el)['paddingLeft'], 10) || 0) + (Number.parseInt(getComputedStyle(el)['paddingRight'], 10) || 0) if ( rangeWidth + padding > el.offsetWidth || el.scrollWidth > el.offsetWidth ) { return true } return false }我們也需要考慮手動開啟這種情況,一般使用一個特殊的
CSS
屬性開啟。const enable = el.getAttribute('enableTooltip')
內容構造和位置計算
tooltip
開啟之後,我們需要構造它的內容和動態計算tooltip
的位置,比如元素髮生縮放和滾動。
構造tooltip
內容的話,我們採用一個vue
組件,然後通過動態組件方式,將其掛載為tooltip
的內容。
<template> <div ref="tooltipRef" class="__CUSTOM_TOOLTIP_ITEM_CONTENT__" :class="arrow" @mouseover="mouseOver" @mouseleave="mouseLeave" v-html="content" ></div> </template> <script lang="ts" setup> import type { TimeoutHTMLElement } from './tooltip' defineProps({ content: { type: String, default: '', }, arrow: { type: String, default: '', }, }) const tooltipRef = ref() let parent: TimeoutHTMLElement onMounted(() => { parent = tooltipRef.value.parentElement }) function mouseOver() { clearTimeout(parent.__hide_timeout__) parent.setAttribute('data-show', 'true') parent.style.visibility = 'visible' } function mouseLeave() { parent.setAttribute('data-show', 'false') parent.style.visibility = 'hidden' } </script> <style scoped lang="scss"> $radius: 8px; @mixin arrow { position: absolute; border-style: solid; border-width: $radius; width: 0; height: 0; content: ''; } .__CUSTOM_TOOLTIP_ITEM_CONTENT__ { position: absolute; border-radius: 4px; padding: 10px; width: 100%; max-width: 260px; font-size: 12px; color: #fff; background: rgb(45 46 50 / 80%); line-height: 18px; &.top::before { @include arrow; top: $radius * (-2); left: calc(50% - #{$radius}); border-color: transparent transparent rgb(45 46 50 / 80%) transparent; } &.top-start::before .top-start::before { @include arrow; top: $radius * (-2); left: $radius; border-color: transparent transparent rgb(45 46 50 / 80%) transparent; } &.top-end::before &.top-end::before { @include arrow; top: $radius * (-2); left: calc(100% - #{$radius * 3}); border-color: transparent transparent rgb(45 46 50 / 80%) transparent; } } </style>此外我們也可以通過
slot
方式自定義提示內容。當然也可以通過屬性查詢[slot='content']
節點,取出其中的innerHTML
,但是這種在更新時需要特殊處理。
function parseSlot(vNode) { const content = vNode.children.find(i => { return i?.data?.slot === 'content' }) const app = createApp({ functional: true, props: { render: Function }, render() { return this.render() } }) const el = document.createElement('div') app.mount(el) return el?.innerHTML }
tooltip
位置計算和自動更新,這裡我們使用@floating-ui/dom
庫。
const __tooltip_el__ = document.createElement('div') __tooltip_el__.className = '__CUSTOM_TOOLTIP__' document.body.appendChild(__tooltip_el__) function createEle() { const tooltip = document.createElement('div') tooltip.className = '__CUSTOM_TOOLTIP_ITEM__' tooltip.style['zIndex'] = '9999' tooltip.style['position'] = 'absolute' __tooltip_el__.appendChild(tooltip) return tooltip } function initTooltip(el: SpecialHTMLElement, binding: DirectiveBinding) { const tooltip = createEle() el.__float_tooltip__ = tooltip as unknown as TimeoutHTMLElement createTooltip(el, binding) autoUpdate(el, tooltip, () => updatePosition(el), { animationFrame: false, ancestorResize: false, elementResize: false, ancestorScroll: true, }) } function createTooltip(el: SpecialHTMLElement, binding: DirectiveBinding) { const tooltip = el.__float_tooltip__ as HTMLElement const { width } = el.getBoundingClientRect() tooltip.style['minWidth'] = width + 'px' const arrow = el.getAttribute('arrow') // eslint-disable-next-line vue/one-component-per-file const app = createApp(tooltipVue, { arrow: arrow, content: binding.value !== void 0 ? binding.value : el.oldVNode, }) app.mount(tooltip) el.__float_app__ = app } function updatePosition(el: SpecialHTMLElement) { const tooltip = el.__float_tooltip__ const middlewares = [] const visible = tooltip?.style?.visibility if (visible !== 'hidden' && visible) { const placement = el?.getAttribute('placement') || 'bottom' let offsetY = el?.getAttribute('offsetY') || el?.getAttribute('offset-y') || 5 let offsetX = el?.getAttribute('offsetX') || el?.getAttribute('offset-x') const offsetXY = el?.getAttribute('offset') if (offsetXY !== null) { offsetX = offsetXY offsetY = offsetXY } if (offsetX || offsetY) { middlewares.push( offset({ mainAxis: Number(offsetY), crossAxis: Number(offsetX), }) ) } computePosition(el, tooltip, { placement: placement as Placement, strategy: 'absolute', middleware: middlewares, }).then(({ x, y }) => { Object.assign(tooltip.style, { top: `${y}px`, left: `${x}px`, }) }) } }
用戶交互
在構造好tooltip
之後,我們需要添加用戶交互行為事件,比如用戶移入目標元素,顯示tooltip
,移除目標元素,隱藏tooltip
。這裡我們加上hide-delay
,即延遲隱藏,在設置offset
時特別有用,同時也支持添加show-delay
,延遲顯示。
function attachEvent(el: HTMLElement) { el?.addEventListener?.('mouseover', mouseOver) el?.addEventListener?.('mouseleave', mouseLeave) } function mouseOver(evt: MouseEvent) { const el = evt.currentTarget as SpecialHTMLElement const tooltip = el?.__float_tooltip__ clearTimeout(tooltip?.__hide_timeout__) if (tooltip) { tooltip.style.visibility = 'visible' tooltip.setAttribute('data-show', 'true') updatePosition(el) } } function mouseLeave(evt: MouseEvent) { const el = evt.currentTarget as SpecialHTMLElement const tooltip = el?.__float_tooltip__ const isShow = tooltip?.getAttribute?.('data-show') const delay = el.getAttribute('hide-delay') || 100 clearTimeout(tooltip?.__hide_timeout__) if (tooltip) { if (delay) { tooltip.__hide_timeout__ = setTimeout(() => { if (isShow === 'true') { tooltip.style.visibility = 'hidden' } }, +delay) } else { if (isShow === 'true') { tooltip.style.visibility = 'hidden' } } } }
內容更新
我們tooltip
的內容並不總是一成不變的,所以我們需要支持內容更新,這個可以在updated
鉤子中完成內容更新。
既然我們支持了指令傳值和slot
方式,所以我們需要考慮三點:
- 指令值變化
slot
內容變化- 開啟和關閉
對於slot
內容變化監測,我們可以對比新舊slot
內容,內容不同則觸發更新。
{ updated(el: SpecialHTMLElement, binding: DirectiveBinding, vNode: VNode) { if (binding.value !== binding.oldValue) { updated(el, binding) } else { const enable = el.getAttribute('enableTooltip') if (enable !== el.oldEnable) { mounted(el, binding, vNode) } else { const newVNode = parseSlot(vNode) if (el.oldVNode !== newVNode) { el.oldVNode = newVNode updated(el, binding) } } } }, } function updated(el: SpecialHTMLElement, binding: DirectiveBinding) { el?.__float_app__?.unmount?.() el.__float_app__ = null createTooltip(el, binding) }
銷毀tooltip
最後,在元素銷毀或者tooltip
關閉的的時候,我們需要把相應的事件等進行銷毀。
function unmounted(el: SpecialHTMLElement) { removeEvent(el) const tooltip = el?.__float_tooltip__ if (tooltip) { __tooltip_el__.removeChild(tooltip) el?.__float_app__?.unmount?.() el.__float_app__ = null el.__float_tooltip__ = null } } function removeEvent(el: HTMLElement) { el?.removeEventListener?.('mouseover', mouseOver) el?.removeEventListener?.('mouseleave', mouseLeave) }
完整代碼
import { DirectiveBinding, VNode, App } from 'vue' import { computePosition, autoUpdate, offset, Placement, } from '@floating-ui/dom' import tooltipVue from './CustomTooltip.vue' export type TimeoutHTMLElement = HTMLElement & { __hide_timeout__: NodeJS.Timeout } export type SpecialHTMLElement = | HTMLElement & { __float_tooltip__: TimeoutHTMLElement | null } & { __float_app__: App | null } & { oldEnable: string | null } & { oldVNode: string } // tooltip 容器 const __tooltip_el__ = document.createElement('div') __tooltip_el__.className = '__CUSTOM_TOOLTIP__' document.body.appendChild(__tooltip_el__) // 判斷是否溢出 function isOverflow(el: SpecialHTMLElement) { if (el.scrollWidth > el.offsetWidth || el.scrollHeight > el.clientHeight) { return true } return false } // 清除 slot function emptySlot(el: SpecialHTMLElement) { const slot = el.querySelector("[slot='content']") if (slot) { el.removeChild(slot) } return slot?.innerHTML } // 卸載 function unmounted(el: SpecialHTMLElement) { removeEvent(el) const tooltip = el?.__float_tooltip__ if (tooltip) { __tooltip_el__.removeChild(tooltip) el?.__float_app__?.unmount?.() el.__float_app__ = null el.__float_tooltip__ = null } } // 移除事件 function removeEvent(el: SpecialHTMLElement) { el?.removeEventListener?.('mouseover', mouseOver) el?.removeEventListener?.('mouseleave', mouseLeave) } // 添加事件 function attachEvent(el: SpecialHTMLElement) { el?.addEventListener?.('mouseover', mouseOver) el?.addEventListener?.('mouseleave', mouseLeave) } // 滑鼠懸浮 function mouseOver(evt: MouseEvent) { const el = evt.currentTarget as SpecialHTMLElement const tooltip = el?.__float_tooltip__ clearTimeout(tooltip?.__hide_timeout__) if (tooltip) { tooltip.style.visibility = 'visible' tooltip.setAttribute('data-show', 'true') updatePosition(el) } } // 滑鼠移出 function mouseLeave(evt: MouseEvent) { const el = evt.currentTarget as SpecialHTMLElement const tooltip = el?.__float_tooltip__ const isShow = tooltip?.getAttribute?.('data-show') const delay = el.getAttribute('hide-delay') || 100 clearTimeout(tooltip?.__hide_timeout__) if (tooltip) { if (delay) { tooltip.__hide_timeout__ = setTimeout(() => { if (isShow === 'true') { tooltip.style.visibility = 'hidden' } }, +delay) } else { if (isShow === 'true') { tooltip.style.visibility = 'hidden' } } } } // 掛載tooltip function mounted( el: SpecialHTMLElement, binding: DirectiveBinding, vNode: VNode ) { const overflow = isOverflow(el) // 手動啟用tooltip const enable = el.getAttribute('enableTooltip') el.oldEnable = enable if (binding.value === void 0 && vNode) { el.oldVNode = parseSlot(vNode) } emptySlot(el) // 顯示延遲 const delay = el.getAttribute('show-delay') || 100 if (overflow || enable === 'true') { if (delay) { setTimeout(() => { initTooltip(el, binding) attachEvent(el) }, +delay) } else { initTooltip(el, binding) attachEvent(el) } } else { unmounted(el) } } // 更新tooltip 只更新內容 function updated(el: SpecialHTMLElement, binding: DirectiveBinding) { el?.__float_app__?.unmount?.() el.__float_app__ = null createTooltip(el, binding) } // 創建元素工廠 function createEle() { const tooltip = document.createElement('div') tooltip.className = '__CUSTOM_TOOLTIP_ITEM__' tooltip.style['zIndex'] = '9999' tooltip.style['position'] = 'absolute' __tooltip_el__.appendChild(tooltip) return tooltip } // 初始化tooltip:創建和計算位置 function initTooltip(el: SpecialHTMLElement, binding: DirectiveBinding) { const tooltip = createEle() el.__float_tooltip__ = tooltip as unknown as TimeoutHTMLElement createTooltip(el, binding) autoUpdate(el, tooltip, () => updatePosition(el), { animationFrame: false, ancestorResize: false, elementResize: false, ancestorScroll: true, }) } // 創建tooltip function createTooltip(el: SpecialHTMLElement, binding: DirectiveBinding) { const tooltip = el.__float_tooltip__ as HTMLElement const { width } = el.getBoundingClientRect() tooltip.style['minWidth'] = width + 'px' const arrow = el.getAttribute('arrow') // eslint-disable-next-line vue/one-component-per-file const app = createApp(tooltipVue, { arrow: arrow, content: binding.value !== void 0 ? binding.value : el.oldVNode, }) app.mount(tooltip) el.__float_app__ = app } // 更新tooltip位置 function updatePosition(el: SpecialHTMLElement) { const tooltip = el.__float_tooltip__ const middlewares = [] const visible = tooltip?.style?.visibility if (visible !== 'hidden' && visible) { const placement = el?.getAttribute('placement') || 'bottom' let offsetY = el?.getAttribute('offsetY') || el?.getAttribute('offset-y') || 5 let offsetX = el?.getAttribute('offsetX') || el?.getAttribute('offset-x') const offsetXY = el?.getAttribute('offset') if (offsetXY !== null) { offsetX = offsetXY offsetY = offsetXY } if (offsetX || offsetY) { middlewares.push( offset({ mainAxis: Number(offsetY), crossAxis: Number(offsetX), }) ) } computePosition(el, tooltip, { placement: placement as Placement, strategy: 'absolute', middleware: middlewares, }).then(({ x, y }) => { Object.assign(tooltip.style, { top: `${y}px`, left: `${x}px`, }) }) } } // 解析slot function parseSlot(vNode: VNode) { const content = (vNode.children as VNode[]).find?.((i: VNode) => { return i?.props?.slot === 'content' }) // eslint-disable-next-line vue/one-component-per-file const app = createApp( { functional: true, props: { render: Function, }, render() { return this.render() }, }, // eslint-disable-next-line vue/one-component-per-file { render: () => { return content }, } ) const el = document.createElement('div') app.mount(el) return el?.innerHTML } export default { mounted(el: SpecialHTMLElement, binding: DirectiveBinding, vNode: VNode) { mounted(el, binding, vNode) }, updated(el: SpecialHTMLElement, binding: DirectiveBinding, vNode: VNode) { if (binding.value !== binding.oldValue) { updated(el, binding) } else { const enable = el.getAttribute('enableTooltip') if (enable !== el.oldEnable) { mounted(el, binding, vNode) } else { const newVNode = parseSlot(vNode) if (el.oldVNode !== newVNode) { el.oldVNode = newVNode updated(el, binding) } } } }, unmounted(el: SpecialHTMLElement) { unmounted(el) }, }
示例
<div v-tooltip='hello world' enableTooltip='true'>tooltip</div> <div v-tooltip enableTooltip='true'> tooltip <div slot='content'> <div>this is a tooltip</div> <button>confirm</button> </div> </div>
總結
在經過二次封裝之後,我們只需要v-tooltip
這樣簡便的操作,即可達到tooltip
的作用,簡化了傳統的書寫流程,對於一些特殊tooltip
內容,我們可以通過slot
方式,定製化更多的提示內容。