一起来封装一个 popper/tooltip 组件吧

在element-plus中,popper组件是tooltip、select、date-picker等触发式弹出层组件的基础,有了它我们就可以封装各种类似功能的组件了。

话不多说,咱们开始吧!

popper 组件依赖于 floating-ui,是对 floating-ui 的高级封装,有兴趣的小伙伴可以先去了解了解。

在线预览

github 地址

最终效果展示

今天的完整代码放在 play/src/components/popper 里了

popper 组件封装

准备工作

首先我们要明白 popper 组件的两个要素

  1. 触发器:trggier
  2. 弹出层内容:popper content

element-plus把所有的popper弹出内容都统一放到了一个 el-popper-container-xxx 的容器中,因此我们要先把这个容器创建出来

typescript 复制代码
// play/src/components/popper/popper.ts
let cachedContainer: HTMLElement
const selector = `van-popper-container-1996`

type PopperContainerType = {
  container: HTMLElement
  selector: string
}

export const usePopperContainer = (): PopperContainerType => {
  if (!cachedContainer && !document.querySelector(`#${selector}`)) {
    const container = document.createElement('div')
    container.id = selector
    cachedContainer = container
    document.body.appendChild(container)
  }

  return {
    container: cachedContainer,
    selector
  }
}
  • popper 组件的props
typescript 复制代码
const props = defineProps({
  effect: {
    type: String as PropType<'light' | 'dark'>,
    default: 'dark'
  },
  content: {
    type: String
  },
  transitionName: {
    type: String,
    default: 'van-popper-fade'
  },
  placement: {
    type: String as PropType<Placement>,
    default: 'bottom-start',
  },
  strategy: {
    type: String as PropType<Strategy>,
    default: 'absolute',
  },
  showArrow: { // 是否显示箭头
    type: Boolean,
    default: true,
  },
  popperClass: {
    type: String,
    default: ''
  }
})

floating-ui 的简单使用

安装依赖

sh 复制代码
pnpm add @floating-ui/dom

一个简单的 popper

html 复制代码
<template>
  <div class="container">
    <span ref="triggerRef" class="demo-btn" @click.stop="handleTrigger">Trigger</span>
    <div v-if="visible" ref="contentRef" class="popper" :style="contentStyle" @mousedown.stop>My Popper</div>
  </div>
</template>

<script lang="ts" setup>
import { computed, ref, watchEffect } from 'vue'
import { computePosition, flip, shift, offset } from '@floating-ui/dom'

const triggerRef = ref()
const contentRef = ref()
const visible = ref(false)

function handleTrigger() {
  visible.value = true

  document.addEventListener('mousedown', () => {
    visible.value = false
  }, { once: true })
}

const middleware = [shift(), flip(), offset(10)]
const x = ref(0)
const y = ref(0)
// 弹出框样式
const contentStyle = computed(() => ({ left: x.value + 'px', top: y.value + 'px' }))
watchEffect(() => {
  if (triggerRef.value && contentRef.value) {
    computePosition(triggerRef.value, contentRef.value, { middleware }).then(data => {
      x.value = data.x
      y.value = data.y
    })
  }
})
</script>

<style lang="less">

.popper {
  position: absolute;
  background: #222;
  color: white;
  font-weight: bold;
  padding: 5px;
  border-radius: 4px;
  font-size: 90%;
}
</style>

解析:

  1. 首先我们要准备两个元素,触发器(trigger)和弹出层内容(popper)
  2. 当触发器和弹出层都挂载后执行 floating-ui 的 computePosition 函数传入需要的参数
  3. 该函数会返回我们位置信息和中间件信息
typescript 复制代码
interface IData {
  middlewareData: Object, // 中间件信息
  placement: string, // 方位
  strategy: 'absolute' | 'fixed', // 浮动策略
  x: number, // x坐标
  y: number // y坐标
}

几个中间件介绍

  • offset 弹出层离触发器的偏移量
  • shift 保持元素在可视范围内
  • flip 切换到其他位置,以确保元素的可见性
  • arrow 箭头(后面有具体使用)

接下来正式进入Popper组件的封装。。。

触发器

触发器是使用者插槽传递过来的,而我们必须要获取到触发器的dom为其注册触发事件,那么我们要注意以下两种情况

html 复制代码
<span v-if="noWrap" ref="triggerRef" class="van-popper__trigger" v-bind="$attrs">
  <slot />
</span>
<component v-else :ref="setTriggerRef" :is="$slots.default" v-bind="$attrs" />
  1. 纯文本:noWrap 为 true 表示插槽为纯文本,我们使用了span标签包了一层
  2. 标签元素:如果是标签元素使用 component 渲染默认插槽
typescript 复制代码
export function useTrigger() {
  const triggerRef = ref()
  const visible = ref(false)
  // 获取默认插槽
  const trggierSlot = useSlots!().default!()
  // 如果是纯文本
  const noWrap = computed(() => trggierSlot[0].patchFlag === 0)
  const open = () => visible.value = true
  const close = () => visible.value = false
  // 设置触发器dom
  const setTriggerRef = (el: HTMLElement | null) => {
    triggerRef.value = el && el.nextElementSibling || null
  }
  // 点击触发器事件
  const onClick = (e: MouseEvent) => {
    e.stopPropagation()
    open()
    setTimeout(() => {
      document.addEventListener('mousedown', onDocumentMousedown, { once: true })
    })
  }

  // 点击其它区域
  const onDocumentMousedown = () => {
    close()
  }
  onMounted(() => {
    if (!triggerRef.value) return
    triggerRef.value.addEventListener('click', onClick)
  })

  onBeforeUnmount(() => {
    triggerRef.value!.removeEventListener('click', onClick)
  })

  return {
    setTriggerRef,
    triggerRef,
    visible,
    noWrap,
    open,
    close
  }
}

// 使用时
const {
  setTriggerRef,
  triggerRef,
  visible,
  noWrap,
  trggierSlot
} = useTrigger()

解析:

  1. useTrigger的主要功能就是,拿到触发器的dom元素为其注册事件(这里只注册了点击事件)

  2. 获取触发器dom元素使用了两种方式,只会有一种生效。如果是纯文本triggerRef挂载后会自动赋值。否则setTriggerRef生效为triggerRef赋值

  3. 提供open和close方法控制弹出内容的显示与隐藏,visible 弹出内容的显示与隐藏,后面会用到

弹出内容

html 复制代码
<Teleport :to="`#${selector}`">
  <Transition :name="transitionName">
    <div
      v-if="visible"
      ref="contentRef"
      :class="['van-popper', `is-${effect}`, popperClass]"
      :style="contentStyle"
      :data-side="side"
      @mousedown.stop
    >
      <slot name="content">{{ content }}</slot>
      <span
        v-if="showArrow"
        ref="arrowRef"
        class="van-popper__arrow"
        :style="arrowStyle"
      >
      </span>
    </div>
  </Transition>
</Teleport>
typescript 复制代码
import { computed, ref, onMounted, watch, unref, getCurrentInstance } from 'vue'
import { usePopperContainer, useFloating, useTrigger } from './popper.ts'
import type { Placement, Strategy } from '@floating-ui/dom'
import { offset, arrow, shift, flip } from '@floating-ui/dom'

const { selector } = usePopperContainer()
const arrowRef = ref()
const middleware = computed(() => {
  const mds = [shift(), flip(), offset(10)]
  if (props.showArrow) {
    mds.push(arrow({ element: arrowRef.value }))
  }
  return mds
})

const placement = ref(props.placement)
const strategy = ref(props.strategy)

const {
  x,
  y,
  contentRef,
  middlewareData,
  update
} = useFloating({ middleware, placement, strategy }, triggerRef)

const side = computed(() => {
  return placement.value.split('-')[0]
})
// 弹出框样式
const contentStyle = computed(() => ({ left: x.value + 'px', top: y.value + 'px' }))
// 箭头样式
const arrowStyle = computed(() => {
  if (!props.showArrow) return {}
  const { arrow } = unref(middlewareData)
  return {
    left: arrow?.x + 'px',
    top: arrow?.y + 'px'
  }
})

解析:

  1. 调用刚刚写好的usePopperContainer返回的selector塞给Teleport
  2. 接着准备floating-ui要使用到的数据,middleware中间件,placement、strategy
  3. 核心部分 useFloating 这个 hook里
typescript 复制代码
type UseFloatingProps = ToRefs<{
  middleware: Array<Middleware>
  placement: Placement
  strategy: Strategy
}>
export const useFloating = ({ middleware, placement, strategy }: UseFloatingProps, triggerRef: Ref<HTMLElement | null>) => {
  const contentRef = ref()
  // popper位置信息
  const x = ref<number>()
  const y = ref<number>()
  // floating-ui 中间件数据
  const middlewareData = ref<ComputePositionReturn['middlewareData']>({})
  const states = {
    x,
    y,
    placement,
    strategy,
    middlewareData,
  } as const
  async function update() {
    if (!triggerRef.value || !contentRef.value) return
    // floating-ui 的 computePosition计算位置
    const data: any = await computePosition(triggerRef.value, contentRef.value, {
      middleware: unref(middleware),
      placement: unref(placement),
      strategy: unref(strategy)
    })
    
    Object.keys(states).forEach(key => {
      (states as any)[key].value = data[key]
    })
  }

  onMounted(() => {
    watchEffect(() => {
      update()
    })
  })
  return {
    ...states,
    update,
    contentRef
  }
}

主要使用floating-ui的 computePosition 根据传入的 触发器、内容dom和配置参数计算最新的位置信息和中间件信息。

  • xy:分别存储弹出内容的横向和纵向位置。
  • middlewareData:用于存储 floating-ui 中间件的计算数据。
  • states:用它的目的是循环赋值。
  • update 函数:用于更新弹出内容的位置。computePosition 计算新的位置数据。这个函数可用于resize窗口自适应的场景(这里并没有实现)

样式

样式不复杂,要考虑的主要是arrow箭头的不同表现

css 复制代码
<style lang="less">
@sides: {
  top: bottom;
  bottom: top;
  left: right;
  right: left;
}
@N: .van-popper;
@{N} {
  --van-arrow-size: 10px;
  --van-popper-content-bg: #fff;
  --van-popper-border: 1px solid #ebedf0;
  border-radius: 4px;
  color: #000;
  background-color: var(--van-popper-content-bg);
  padding: 10px 12px;
  border: var(--van-popper-border);
  position: absolute;
  white-space: nowrap;
  transition: opacity .3s;
  font-size: 13px;
  z-index: 1000;
  width: max-content;
  
  &__arrow {
    position: absolute;
    width: 10px;
    height: 10px;
    background: var(--van-popper-content-bg);
    transform: rotate(45deg);
  }

  each(@sides, {
    &[data-side^='@{value}'] {
      @{N}__arrow {
        @{key}: -5px;
      }
      @{N}__arrow {
        border-@{key}: var(--van-popper-border);
      }
      @{N}__arrow when (@value =top) {
        border-right: var(--van-popper-border);
      }
      @{N}__arrow when (@value =bottom) {
        border-left: var(--van-popper-border);
      }
      @{N}__arrow when (@value =left) {
        border-top: var(--van-popper-border);
      }
      @{N}__arrow when (@value =right) {
        border-bottom: var(--van-popper-border);
      }
    }
  });

  &.is-dark {
    --van-popper-content-bg: #000;
    color: #fff;
    border: none;

    @{N}__arrow {
      border-color: transparent;
    }
  }
}

.van-popper-fade-enter-from,
.van-popper-fade-leave-to {
  opacity: 0;
}
</style>

以上就是popper的基本封装,还有很多可扩展的地方,比如:触发的方式还可以有mouseenter/mouseleave、mousedown/mouseup、focus/blur等

使用

html 复制代码
<VanPopper
  effect="dark"
  content="你好呀!"
>
  noWrap
</VanPopper>
<VanPopper
  effect="dark"
  content="你也好呀!"
>
  <span class="demo-btn">wrap</span>
</VanPopper>

最后

本来是要封装一个date-picker组件的,没想到popper组件写了这么久。那就下次再写吧!

有精力的朋友可以先写写,无非就是把Trigger换成input,content换成日期组件。vangle组件库已经实现了date-picker可供参考

今天就到这吧!如果文章对你有帮助,点个赞再走呗,嘻嘻

相关推荐
粉末的沉淀25 分钟前
vue:Vite项目中高效管理纯色SVG图标的方案
前端·javascript·vue.js
FlyWIHTSKY26 分钟前
JavaScript 和 TypeScript 分别是什么,可以相互写吗
javascript·ubuntu·typescript
卤蛋fg626 分钟前
vxe-table 列宽与行高拖拽调整:让表格布局极其灵活,拖拽功能非常强大
vue.js
云水一下1 小时前
TypeScript 从零基础到精通(七):从配置到全栈项目落地
前端·javascript·typescript
向日的葵0061 小时前
Vue 路由传参的三种方式(三)
vue.js·路由
如果超人不会飞1 小时前
TinyVue Checkbox复选框组件使用指南
前端·vue.js
如果超人不会飞2 小时前
TinyVue Radio单选框组件使用指南
vue.js
鲁班小子2 小时前
Vite resolve.dedupe 使用教程
vue.js·vite
如果超人不会飞2 小时前
TinyVue Input输入框组件使用指南
vue.js
如果超人不会飞2 小时前
TinyVue Pager分页组件使用指南
前端·vue.js