抽象表格组件中单元格的提示框

date
Jun 17, 2022
slug
table-cell-tooltip
status
Published
tags
Javascript
Reactjs
Vue3.x
Vue2.x
Vuejs
summary
一次配置全部生效,提升开发效率
type
Post

问题由来

在我们日常开发中,不论使用什么框架&UI库,总会有表格单元格展示不全的情况。这个时候自然而然想到使用<Tooltip> 组件,但是会面临以下问题:
  1. 表格宽度自适应式时,需要根据字体大小以及内容长度来判断是否溢出,然后显示
  1. 需要不断重复编写 <Tooltip> 代码

思路探索

针对问题2,我们只需要自己编写一个生成 Tooltip 组件的函数即可。在单元格渲染完毕后,自动调用即可,这样就不需要再重复编写相同的代码。
针对问题1,因为在代码编写时,单元格的 DOM 没有被渲染,所以此时只能通过设置的字体大小和内容长度来计算是否需要显示 Tooltip。这里的重点在于如何获取真实宽度。真实宽度只能在 render (页面内容呈现出来)后获取,结合 Tooltip 出现的交互,用户鼠标移入该单元格后 Tooltip 显示。所以我们只需要在单元格上添加事件 Mouseenter/Mouseleave 即可。

具体实现

示例语法遵循 Vue3.xReact 类似。示例代码不可用于生产🙅‍♂️,目的是阐述思路。

自定义单元格

// 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>
	)
}

// ...
这里最重要的是isCellOverflowgetCellContent 函数,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/dompopper.js 团队使用Typescript 编写的新项目,支持 Tree-Shaking 等现代化特性。详情查看:
最后记得编写函数让 Tooltip 消失,监听滚动时使用节流函数避免性能消耗。

参考


© i7eo 2017 - 2022