popper/tooltip 组件

发布时间 2023-12-15 17:02:00作者: 柯基与佩奇

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

popper 组件依赖于 floating-ui,是对 floating-ui 的高级封装。

最终效果展示

03.gif

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

popper 组件封装

准备工作

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

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

02.png

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

// 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
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 的简单使用

安装依赖

pnpm add @floating-ui/dom

一个简单的 popper

<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. 该函数会返回位置信息和中间件信息
interface IData {
  middlewareData: Object; // 中间件信息
  placement: string; // 方位
  strategy: "absolute" | "fixed"; // 浮动策略
  x: number; // x坐标
  y: number; // y坐标
}

几个中间件介绍

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

05.gif

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

触发器

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

<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 渲染默认插槽
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 弹出内容的显示与隐藏,后面会用到

弹出内容

<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>
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 里
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 箭头的不同表现

<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 0.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 等

使用

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

04.gif