抽象表格组件中单元格的提示框
date
Jun 17, 2022
slug
table-cell-tooltip
status
Published
tags
Javascript
Reactjs
Vue3.x
Vue2.x
Vuejs
summary
一次配置全部生效,提升开发效率
type
Post
问题由来
在我们日常开发中,不论使用什么框架&UI库,总会有表格单元格展示不全的情况。这个时候自然而然想到使用
<Tooltip>
组件,但是会面临以下问题:- 表格宽度自适应式时,需要根据字体大小以及内容长度来判断是否溢出,然后显示
- 需要不断重复编写
<Tooltip>
代码
思路探索
针对问题2,我们只需要自己编写一个生成
Tooltip
组件的函数即可。在单元格渲染完毕后,自动调用即可,这样就不需要再重复编写相同的代码。针对问题1,因为在代码编写时,单元格的 DOM 没有被渲染,所以此时只能通过设置的字体大小和内容长度来计算是否需要显示
Tooltip
。这里的重点在于如何获取真实宽度。真实宽度只能在 render (页面内容呈现出来)后获取,结合 Tooltip
出现的交互,用户鼠标移入该单元格后 Tooltip
显示。所以我们只需要在单元格上添加事件 Mouseenter/Mouseleave
即可。具体实现
示例语法遵循
Vue3.x
, React
类似。示例代码不可用于生产🙅♂️,目的是阐述思路。自定义单元格
// Cell.tsx
//...
const instances = new Map<string, any>()
const cellRef = ref<HTMLElement | null>(null)
/**
* 获取单元格真实宽度判断是否溢出
* @param cellContentEl
* @returns
*/
function isCellOverflow(cellContentEl: HTMLElement) {
if(!cellContentEl) return false
return cellContentEl.scrollWidth > cellContentEl.clientWidth
}
/**
* 获取单元格内容(需要考虑单元格为组件的情况,这里需要获取真实 DOM)
* @param cellContentEl
* @returns
*/
function getCellContent(cellContentEl: HTMLElement) {
if(!cellContentEl) return ''
return (cellContentEl.innerText ?? cellContentEl.textContent ?? '').trim()
}
/**
* 展示 Tooltip
* @param instances
* @param params
* @returns
*/
function showCellTooltip(
instances: Map<string, any>,
row: Record<string, any>
) {
const id = `row_${row.id}`
const el = unref(cellRef)
const title = getCellContent(el)
const isCellOverflow = isCellOverflow(el)
let instance
if (!instances.has(id)) {
instance = $Tooltip(el as HTMLElement, {
placement: 'top',
title,
id,
delay: 100,
})
instances.set(id, instance)
} else {
instance = instances.get(id)
}
isCellOverflow && instance?.showTooltip()
}
/**
* 消失 Tooltip
* @param instances
* @param params
* @returns
*/
function hideCellTooltip(
instances: Map<string, any>,
row: Record<string, any>
) {
const id = `row_${row.id}-${column.id}`
const instance = instances.get(id)
instance?.hideTooltip()
}
const handleCellMouseenter = ({ row }) => {
showCellTooltip(instances, row)
}
const handleCellMouseleave = ({ row }) => {
hideCellTooltip(instances, row)
}
return () => {
return (
<div class="custom-cell">
<div
class="custom-cell-content"
ref={cellRef}
onMouseenter={handleCellMouseenter}
onMouseleave={handleCellMouseleave}
>
{slots.default?()}
</div>
</div>
)
}
// ...
这里最重要的是
isCellOverflow
与getCellContent
函数,isCellOverflow
来判断是否溢出;getCellContent
用来兜底单元格的内容,因为单元格支持可以自定义渲染,我们在不能保证单元格最终渲染为什么的时候,只能这样做(这也是通过 Mouseenter/Mouseleave
来实现的优势)创建函数调用 Tooltip
// useTooltip.tsx
import { onMounted, onUnmounted } from 'vue'
import { arrow, computePosition, flip, hide, offset, shift } from '@floating-ui/dom'
import { useThrottleFn } from '@vueuse/core'
import type { Placement } from '@floating-ui/dom'
const CLS = `ta-popper`
interface TooltipProps {
/** tooltip 内容 */
title: string
/** tooltip 位置 */
placement: Placement
}
/**
* 生成 dom
* @param param
* @returns
*/
function createElement({ tagName = 'div', className = '' }) {
const el = document.createElement(tagName)
el.className = className
return el
}
/**
* 生成 Tooltip dom
* @param props
* @returns
*/
function createAntvTooltip(props: TooltipProps) {
const tooltipEl = createElement({
className: `${CLS}`,
})
const contentEl = createElement({
tagName: 'span',
className: `${CLS}-content`,
})
contentEl.innerHTML = props.title ?? ''
const arrowEl = createElement({
className: `${CLS}-arrow`,
})
tooltipEl.appendChild(contentEl)
tooltipEl.appendChild(arrowEl)
return {
tooltipEl,
arrowEl,
}
}
export function $Tooltip(cellEl: HTMLElement, props: TooltipProps) {
if (!cellEl) return
const { tooltipEl, arrowEl } = createAntvTooltip(props)
/**
* 使用 @floating-ui/dom 来实时更新 Tooltip 的位置,包括三角形位置,内容位置
* @param
* @returns
*/
function update() {
computePosition(cellEl, tooltipEl, {
placement: 'top-start',
middleware: [hide(), offset(6), flip(), shift({ padding: 6 }), arrow({ element: arrowEl })],
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(tooltipEl.style, {
left: `${x}px`,
top: `${y}px`,
})
const staticSide: any = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[placement.split('-')[0]]
Object.assign(arrowEl.style, {
// left: middlewareData.arrow?.x != null ? `${middlewareData.arrow?.x}px` : '',
left: '10px',
top: middlewareData.arrow?.y != null ? `${middlewareData.arrow?.y}px` : '',
right: '',
bottom: '',
[staticSide]: '-4px',
})
tooltipEl.dataset.popperPlacement = placement
})
}
function showTooltip() {
setTimeout(() => {
document.body.appendChild(tooltipEl)
update()
}, props.delay)
}
function hideTooltip() {
setTimeout(() => {
tooltipEl.remove()
}, props.delay)
}
return {
showTooltip,
hideTooltip,
}
}
/**
* 监听全局滚动,滚动时让 Tooltip 消失
* @param instances Tooltip 生成的实例
* @returns
*/
export function useHideTooltips(instances) {
if (!instances) return
const listener = () => {
for (const instance of instances.values()) {
if (instance) instance?.hideTooltip()
}
}
const handler = useThrottleFn(listener, 30)
onMounted(() => {
document.addEventListener('mousewheel', handler)
})
onUnmounted(() => {
document.removeEventListener('mousewheel', handler)
})
}
因为目前主流库都没有提供函数式调用
Tooltip
的方法,所以这部分需要自行编写。需要注意的是,自己编写时会遇到 Tooltip
在视口空间不够时不会自动调整位置的问题,所以我们需要借助@floating-ui/dom
,当前主流库都在使用 popper.js
但是这个包不支持 Tree-Shaking
,会导致打包产物变大。@floating-ui/dom
是popper.js
团队使用Typescript
编写的新项目,支持 Tree-Shaking
等现代化特性。详情查看:最后记得编写函数让
Tooltip
消失,监听滚动时使用节流函数避免性能消耗。