https://github.com/yanghuanrong/RelaxPlus,学习(下)

132 阅读1分钟

因为公司网络问题,只得将GitHub找到的优秀源码放到帖子上,在公司不忙时来学习。

Select

<template>
  <div class="x-select">
    <div
      class="x-select-input"
      @click.prevent="toggle"
      @mouseover="mouseover"
      @mouseout="mouseout"
    >
      <div class="x-select-array">
        <template v-if="multiple">
          <div class="x-select-array-content" v-if="state.length">
            <span class="x-con-array">{{ state[0] }}</span>
            <span class="x-clearable-array" @click.stop="handelClear">
              <i class="x-icon-x"></i>
            </span>
          </div>
          <span v-if="state.length > 1">+ {{ state.length - 1 }}</span>
        </template>
        <div v-if="!multiple && state.length">{{ state }}</div>
      </div>

      <input
        readonly
        :placeholder="state.length ? '' : placeholder"
        class="x-input"
        :class="{
          'is-focus': isShow,
          'is-blur': !isShow,
        }"
        @focus="focus"
      />

      <i class="x-arrow" v-show="!isClear" :class="{ 'is-active': isShow }"></i>
      <div class="x-clearable" v-show="isClear" @click.stop="handelClear">
        <i class="x-icon-x"></i>
      </div>
    </div>

    <teleport to="body">
      <transition name="scaleY" ref="trigger">
        <div
          class="x-trigger x-select-option"
          @click.stop
          :style="rect"
          v-show="isShow"
        >
          <div class="x-select-search" v-if="search">
            <div class="x-from-input x-input-icon-before">
              <i class="x-before x-icon-search"></i>
              <input
                class="x-input x-input-sm"
                v-model="searchValue"
                @click.stop
              />
            </div>
          </div>
          <div class="x-select-item">
            <slot></slot>
          </div>
        </div>
      </transition>
    </teleport>
  </div>
</template>

<script>
import {
  ref,
  getCurrentInstance,
  reactive,
  provide,
  computed,
  watch,
} from 'vue'
import useToggle from '../../utils/togger'
import emitter from '../../utils/emiter'
import { isArray } from '../../utils/isType'

export default {
  name: 'Select',
  props: {
    modelValue: [Array, String],
    placeholder: String,
    search: [String, Boolean],
  },
  setup(props, { emit }) {
    provide('Select', getCurrentInstance())

    const { toggle, isShow, focus, rect, trigger, hide } = useToggle()

    const searchValue = ref('')
    watch(isShow, (value) => {
      if (value) {
        searchValue.value = ''
      }
    })

    const multiple = computed(() => isArray(props.modelValue))
    const state = multiple.value ? reactive([]) : ref('')

    const { on, broadcast } = emitter()
    on('selectOption', ({ label, value, checked }) => {
      emit('update:modelValue', value)

      if (multiple.value) {
        const labelIndex = state.indexOf(label)
        labelIndex === -1 && checked === true && state.unshift(label)
        labelIndex !== -1 && checked === false && state.splice(labelIndex, 1)
      } else {
        state.value = label
        hide()
      }
    })

    on('selectDefault', ({ label, value, checked }) => {
      if (checked) {
        if (multiple.value) {
          state.unshift(label)
        } else {
          state.value = label
        }
      }
    })

    const clear = useClear(state, multiple.value)

    const handelClear = () => {
      if (multiple.value) {
        const modelValue = props.modelValue
        modelValue.pop()
        state.shift() // state 用的unshift插入 所以需要从第一个开始删
        emit('update:modelValue', modelValue)
      } else {
        state.value = ''
        emit('update:modelValue', '')
      }
    }

    if (props.search) {
      let time
      watch(searchValue, (value) => {
        clearTimeout(time)
        time = setTimeout(() => {
          broadcast('search', value)
        }, 100)
      })
    }

    return {
      focus,
      rect,
      multiple,
      state,
      trigger,
      searchValue,

      toggle,
      isShow,
      handelClear,
      ...clear,
    }
  },
}

function useClear(state, multiple) {
  const isClear = ref(false)

  const mouseover = () => {
    if (!multiple && state.value.length) {
      isClear.value = true
    }
  }
  const mouseout = () => {
    if (!multiple) {
      isClear.value = false
    }
  }

  return {
    isClear,
    mouseover,
    mouseout,
  }
}
</script>
<style lang="less">
.x-select {
  position: relative;
  display: inline-block;
  user-select: none;

  .x-select-input{
    position: relative;
    cursor: pointer;

    &.x-select-input-disabled{
      cursor: not-allowed;

      .x-input{
        cursor: not-allowed;
      }
    }

    &>.x-input{
      cursor: pointer;
    }

    &>i{
      right: 10px;

      &.x-arrow{
        position: absolute;
      }
    }

    .x-clearable{
      position: absolute;
      z-index: 1;
      height: 100%;
      top: 0;
      width: 30px;
      align-items: center;
      justify-content: center;
      display: flex;

      i{
        transition: all 0.25s ease-in-out;
        color: var(--line-color);
      }

      right: 0;
      cursor: pointer;

    }

    .x-select-array{
      position: absolute;
      top: 0;
      left: 0;
      bottom: 0;
      right: 30px;
      padding-left: 8px;
      flex-wrap: wrap;
      display: flex;
      align-items: center;
      pointer-events: none;

      &>span{
        padding: 2px 8px;
        background-color: var(--line-color);
        border-radius: 4px;
      }

      .x-select-array-content{
        max-width: 70%;
        padding: 2px 8px;
        background-color: var(--line-color);
        border-radius: 4px;
        margin-right: 5px;
        display: flex;
        pointer-events: all;

        &>span {
          float: left;
        }
        .x-con-array{
          overflow: hidden;
          flex:1;
          display: block;
          white-space:nowrap;flex:1;
          text-overflow: ellipsis;
        }
        .x-clearable-array{
          width: 14px;
          width: auto;
          display: block;
          margin-left: 5px;
          cursor: pointer;
        }
      }
    }


  }
}

.x-select-option{
    .x-option{
      white-space: nowrap;
      cursor: pointer;
      padding-left: 25px;
      padding: 0 12px;
      height: 30px;
      line-height: 30px;
      overflow: hidden;
      color: var(--sub-text-color);

      &.is-checked{
        color: rgb(var(--primary));
        background-color: rgb(var(--line-color), .1);
        font-weight: bold;
      }

      &:hover{
        background-color: rgb(var(--primary), .2)
      }

      // 禁用状态
      &.is-disabled{
        cursor: no-drop;
        color: var(--sub-text-color);
        opacity: .4;
        &:hover{
          background-color: initial;
        }
      }
    }
    
    .x-select-item{
      max-height: 220px;
      overflow-y: auto;

      &::-webkit-scrollbar {
        width: 8px; /*高宽分别对应横竖滚动条的尺寸*/
        height: 1px;
      }
      &::-webkit-scrollbar-thumb {
        border-radius: 6px;
        // -webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
        background: var(--line-color);
      }
      &::-webkit-scrollbar-track {
        // -webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
        border-radius: 5px;
        background: transparent;
      }
    }

    .x-select-search{
      padding: 10px;
    }
  }

</style>

Option

<template>
  <CollapseTransition>
    <div
      class="x-option"
      :class="{
        'is-checked': isChecked,
        'is-disabled': disabled,
      }"
      @click.stop="handerClick"
      v-show="isShow"
    >
      {{ label || value }}
    </div>
  </CollapseTransition>
</template>

<script>
import {
  inject,
  computed,
  watchEffect,
  reactive,
  ref,
  getCurrentInstance,
} from 'vue'
import CollapseTransition from '../transitions/collapse-transition.vue'
import emitter from '../../utils/emiter'
import { isArray } from '../../utils/isType'

export default {
  name: 'Option',
  props: {
    value: String,
    label: String,
    disabled: Boolean,
  },
  components: {
    CollapseTransition,
  },
  setup(props) {
    const { value, label, disabled } = props
    const { dispatch, on } = emitter()
    const uid = getCurrentInstance().uid

    const Select = inject('Select', { props: {} })

    const state = reactive({
      modelValue: null,
    })

    watchEffect(() => {
      state.modelValue = Select.props.modelValue
    })

    const model = computed({
      get() {
        return state.modelValue
      },
      set({ checked, value }) {
        if (isArray(model.value)) {
          const modelValue = model.value
          const labelIndex = modelValue.indexOf(value)

          labelIndex === -1 && checked === true && modelValue.push(value)
          labelIndex !== -1 &&
            checked === false &&
            modelValue.splice(labelIndex, 1)

          state.modelValue = modelValue
          dispatch('selectOption', {
            value: modelValue,
            label,
            checked,
          })
        } else {
          state.modelValue = value
          dispatch('selectOption', {
            value,
            label,
            checked,
          })
        }
      },
    })

    const isChecked = computed(() => {
      if (isArray(model.value)) {
        return model.value.includes(value)
      } else {
        return model.value === value
      }
    })

    const handerClick = () => {
      if (disabled) return
      model.value = {
        checked: !isChecked.value,
        value,
      }
    }

    dispatch('selectDefault', {
      value,
      label,
      checked: isChecked.value,
    })

    const isShow = ref(true)
    on('search', (searchValue) => {
      isShow.value = props.value.search(searchValue) > -1
    })

    return {
      handerClick,
      isShow,
      isChecked,
      uid,
    }
  },
}
</script>

Slider

<template>
  <div class="x-slider" @mouseenter="handleEnter" @mouseleave="handleLeave">
    <tooltip v-if="range" :content="start.num" v-model="isTooltip">
      <div
        class="x-slider-dot"
        :style="{
          left: `${start.x}%`,
        }"
        @mousedown="handleDown($event, 'start')"
      ></div>
    </tooltip>

    <tooltip :content="end.num" v-model="isTooltip">
      <div
        class="x-slider-dot"
        :style="{
          left: `${end.x}%`,
        }"
        @mousedown="handleDown($event, 'end')"
      ></div>
    </tooltip>

    <div class="x-slider-bar" :style="propress"></div>

    <div class="x-slider-step" v-if="steps > 0">
      <i v-for="i in steps" :key="i"></i>
    </div>
  </div>
</template>

<script>
import {
  reactive,
  ref,
  toRefs,
  getCurrentInstance,
  onMounted,
  computed,
  watchEffect,
} from 'vue'
import { on, off } from '../../utils/dom'
import tooltip from '../tooltip/index'
import { isArray } from '../../utils/isType'
export default {
  name: 'Slider',
  components: {
    tooltip,
  },
  props: {
    modelValue: [Number, Array],
    max: {
      type: Number,
      default: 100,
    },
    min: {
      type: Number,
      default: 0,
    },
    step: Boolean,
    showTooltip: Boolean,
  },
  emits: ['update:modelValue'],
  setup(props, { emit }) {
    const { modelValue, max, min, step, showTooltip } = toRefs(props)
    const instance = getCurrentInstance()
    const propress = reactive({})
    const isTooltip = ref(false)
    const space = ref(0)
    const range = ref(isArray(modelValue.value))
    const steps = ref(0)
    const isHover = ref(false)
    const isDrag = ref(false)

    const start = reactive({
      num: range.value ? modelValue.value[0] : 0,
    })
    const end = reactive({
      num: range.value ? modelValue.value[1] : modelValue.value,
    })

    const state = reactive({
      modelValue: null,
    })

    const model = computed({
      get() {
        return state.modelValue
      },
      set({ start, end }) {
        if (range.value) {
          const modelValue = model.value
          modelValue[0] = start
          modelValue[1] = end
          emit('update:modelValue', state.modelValue)
        } else {
          state.modelValue = end
          emit('update:modelValue', state.modelValue)
        }
      },
    })

    onMounted(() => {
      useSpace()
      useSlider()

      window.addEventListener('resize', () => {
        useSpace()
        useSlider()
      })
    })

    const usePos = (dot) =>
      ((dot.num - min.value) / (max.value - min.value)) * 100

    watchEffect(() => {
      isTooltip.value = isDrag.value || isHover.value || showTooltip.value

      state.modelValue = modelValue.value
      if (!range.value) {
        end.num = modelValue.value
        end.x = usePos(end)
        propress.width = end.x + '%'
      }
    })

    const useSpace = () => {
      const el = instance.vnode.el
      propress.maxWidth = el.getBoundingClientRect().width
      space.value = propress.maxWidth / (max.value - min.value)
      step.value && (steps.value = max.value - min.value + 1)
    }

    const useSlider = () => {
      start.x = range.value ? usePos(start) : 0
      end.x = usePos(end)

      let a = start
      let b = end
      if (end.x >= start.x) {
        a = end
        b = start
      }
      propress.width = a.x - b.x + '%'
      propress.left = b.x + '%'
      model.value = {
        start: b.num,
        end: a.num,
      }
    }

    const handleDown = (e, type) => {
      const dot = type === 'start' ? start : end
      const touchX = e.screenX - dot.num * space.value + space.value * min.value
      isDrag.value = true
      on(document, 'mousemove', move)
      on(document, 'mouseup', () => {
        isDrag.value = false
        off(document, 'mousemove', move)
      })

      function move(e) {
        e.preventDefault()
        let mx = e.screenX - touchX
        mx < 0 && (mx = 0)
        mx > propress.maxWidth && (mx = propress.maxWidth)
        const num = Math.round(mx / space.value) + min.value
        dot.num = num
        useSlider()
      }
    }

    function handleEnter() {
      isHover.value = true
    }

    function handleLeave() {
      isHover.value = false
    }

    return {
      handleDown,
      handleEnter,
      handleLeave,
      range,
      end,
      start,
      steps,
      propress,
      isTooltip,
    }
  },
}
</script>
<style lang="less">
.x-slider{
  height: 6px;
  background-color: var(--hover-background-color);
  border-radius: 2em;
  cursor: pointer;
  position: relative;
  margin: 14px 6px 10px;

  .x-slider-dot{
    position: absolute;
    width: 12px;
    height: 12px;
    border-radius: 50%;
    background-color: rgb(var(--white));
    border: 2px solid rgb(var(--primary));
    margin-top: -3px;
    z-index: 3;
    margin-left: -6px;
    transition: box-shadow .3s ease;

    &:active{
      box-shadow: 0 0 0 4px rgb(var(--primary), .4);
    }
  }

  .x-slider-bar{
    position: absolute;
    top: 0;
    bottom: 0;
    z-index: 2;
    border-radius: 2em;
    background-color: rgb(var(--primary));
  }

  .x-slider-step{
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    right: 0;
    z-index: 1;
    display: flex;
    justify-content: space-between;
    
    i{
      display: block;
      width: 6px;
      height: 6px;
      background: var(--shadow);
      border-radius: 50%;
      
      &:nth-child(1),&:nth-last-child(1){
        visibility: hidden;
      }
      
    }
  }
}
</style>

Step

<template>
  <div class="steps-item" :class="className">
    <div class="step-icon">
      <i :class="icon" v-if="icon"></i>
      <template v-else>
        <i class="x-icon-check" v-if="success"></i>
        <i v-else>{{ num + 1 }}</i>
      </template>
    </div>
    <div class="step-line"></div>
    <div class="step-title">
      <span>
        {{ title }}
      </span>
      <p>
        {{ description }}
      </p>
      <slot></slot>
    </div>
  </div>
</template>

<script>
import emitter from "../../utils/emiter";
import { computed, getCurrentInstance, inject, ref } from "vue";

export default {
  name: "Step",
  props: {
    title: String,
    description: String,
    icon: String,
  },
  setup() {
    const { dispatch } = emitter();

    const instance = getCurrentInstance();
    dispatch("step-item", instance.uid);

    const steps = inject("steps");
    const num = ref(steps.indexOf(instance.uid));

    const active = inject("step-active");
    const current = computed(() => active.value === num.value);
    const success = computed(() => active.value > num.value);

    const className = computed(() => ({
      current: current.value,
      success: success.value,
    }));

    return {
      num,
      success,
      className,
    };
  },
};
</script>

Steps

<template>
  <div class="x-steps">
    <slot></slot>
  </div>
</template>

<script>
import emitter from "../../utils/emiter";
import { reactive, watchEffect, provide, toRefs, ref } from "vue";
export default {
  name: "Steps",
  props: {
    current: Number,
  },
  setup(props) {
    const { on } = emitter();
    const items = reactive([]);

    const { current } = toRefs(props);
    const active = ref(0);

    watchEffect(() => {
      current && (active.value = current.value);
    });

    on("step-item", (uid) => {
      items.push(uid);
    });

    provide("steps", items);
    provide("step-active", active);
  },
};
</script>
<style lang="less">
.x-steps {
  position: relative;
  display: flex;

  .steps-item {
    flex: 1;
    position: relative;
    display: flex;
    flex-direction: column;

    --color: rgba(var(--primary), 1);

    &.current {
      .step-title,
      .step-icon {
        color: var(--color);
      }
    }
    &.success {
      color: var(--color);
      .step-icon {
        color: #fff;
        background-color: var(--color);
        border-color: var(--color);
      }
    }
    color: #ccc;

    .step-line {
      position: absolute;
      height: 1px;
      width: calc(100% - 10px);
      left: 0;
      top: 13px;
      background-color: currentColor;
    }
    .step-icon {
      overflow: hidden;
      border: 1px solid currentColor;
      display: flex;
      width: 25px;
      height: 25px;
      border-radius: 50%;
      align-items: center;
      justify-content: center;
      background-color: #fff;
      position: relative;
      z-index: 1;

      i {
        font-style: normal;
      }
    }
    .step-title {
      margin-top: 5px;
      font-size: 12px;
      text-align: center;
    }

    &:not(:first-child):not(:last-child) {
      .step-icon,
      .step-title {
        transform: translateX(-50%);
      }
    }
    &:first-child {
      align-items: flex-start;
      .step-title {
        text-align: left;
      }
    }
    &:last-child {
      position: absolute;
      right: 1px;
      width: auto;
      text-align: right;
      align-items: flex-end;

      .step-line {
        display: none;
      }
      .step-title {
        text-align: right;
      }
    }
  }
}

</style>

SubMenu

<template>
  <li class="x-submenu" :class="{ 'is-active': isActive }" @click="handleClick">
    <div class="x-menu-title">
      <slot name="title"></slot>
      <i class="x-arrow" :class="{ 'is-active': isActive }"></i>
    </div>
    <transition name="scaleY" ref="trigger" v-if="horizontal">
      <ul class="x-menu" v-show="isActive">
        <slot></slot>
      </ul>
    </transition>
    <CollapseTransition v-else>
      <ul class="x-menu" v-show="isActive">
        <slot></slot>
      </ul>
    </CollapseTransition>
  </li>
</template>

<script>
import { inject, ref, toRefs, watch, getCurrentInstance, onMounted } from 'vue'
import CollapseTransition from '../transitions/collapse-transition.vue'
import emiter from '../../utils/emiter'

export default {
  name: 'SubMenu',
  components: {
    CollapseTransition,
  },
  props: {
    name: [String, Number],
  },
  setup(props) {
    const { name } = toRefs(props)
    const { dispatch, on } = emiter()
    const menu = inject('menu', { props: {} })
    const isActive = ref(false)
    const isChild = ref('')
    const Instance = getCurrentInstance()
    const horizontal = menu.props.mode === 'horizontal'

    watch(menu.currName, (value) => {
      if (menu.props.uniqueOpened) {
        if (value !== name.value) {
          isActive.value = false
        }
        if (isChild.value === value) {
          isActive.value = true
        }
      }
      if (horizontal) {
        if (value !== name.value) {
          isActive.value = false
        }
      }
    })
    on('item-click', (item) => {
      isChild.value = item
    })

    const handleClick = () => {
      dispatch('item-click', name.value)
      isActive.value = !isActive.value
    }

    onMounted(() => {
      if (horizontal) {
        document.addEventListener('click', (e) => {
          const el = Instance.vnode.el
          if (!el.contains(e.target)) {
            isActive.value = false
          }
        })
      }
    })

    return {
      horizontal,
      handleClick,
      isActive,
    }
  },
}
</script>

Switch

<template>
  <button
    class="x-switch"
    @click="handerClick"
    :class="[
      `x-switch-${type}`,
      {
        'x-switch-checked': modelValue,
        'x-switch-disabled': disabled,
      },
    ]"
  >
    <span class="x-switch-inner">
      <slot v-if="modelValue" name="open"></slot>
      <slot v-else name="close"></slot>
    </span>
  </button>
</template>

<script>
import { toRefs } from 'vue'
export default {
  name: 'Switch',
  props: {
    type: {
      type: String,
      default: 'primary',
      validator: (value) =>
        ['success', 'primary', 'warning', 'info', 'danger'].includes(value),
    },
    disabled: Boolean,
    modelValue: Boolean,
  },
  setup(props, { emit }) {
    const { modelValue, disabled } = toRefs(props)

    const handerClick = () => {
      if (disabled.value) {
        return
      }
      emit('update:modelValue', !modelValue.value)
      emit('change', !modelValue.value)
    }

    return {
      handerClick,
    }
  },
}
</script>
<style lang="less">
.x-switch {
  min-width: 44px;
  height: 22px;
  position: relative;
  border: none;
  border-radius: 20px;
  display: inline-block;
  cursor: pointer;
  outline: none;
  text-align: right;
  transition: all .3s ease;
  color: rgb(var(--white));
  font-size: 12px;
  padding-left: 25px;
  padding-right: 10px;
  background-color: var(--hover-background-color);

  &::before{
    content: "";
    width: 16px;
    height: 16px;
    position: absolute;
    top: 3px;
    left: 3px;
    border-radius: 20px;
    overflow: hidden;
    background-color: transparent;
    border: 3px solid #FFF;
    transform: translateX(0);
    transition: .3s;
  }

  &.x-switch-disabled{
    opacity: 0.4;
    cursor: not-allowed;
  }

  &.x-switch-checked{
    text-align: left;
    padding-right: 25px;
    padding-left: 10px;

    &.x-switch-primary{
      background-color: rgb(var(--primary));
    }
    &.x-switch-info{
      background-color: rgb(var(--info));
    }
    &.x-switch-danger{
      background-color: rgb(var(--danger));
    }
    &.x-switch-success{
      background-color: rgb(var(--success));
    }
    &.x-switch-warning{
      background-color: rgb(var(--warning));
    }

    &.x-switch-disabled{
      &::before{
        border-color: #fff;
        background-color: #FFF;
      }
    }

    &::before{
      left: 100%;
      margin-left: -3px;
      background-color: #FFF;
      transform: translateX(-100%)
    }

  }

  &:active::before{
    width: 21px;
  }


}


</style>

TabPane

<template>
  <div class="x-tabs-item" v-show="isShow">
    <slot></slot>
  </div>
</template>

<script>
import { computed, inject } from 'vue'
import emtter from '../../utils/emiter'
export default {
  name: 'TabPane',
  props: {
    label: String,
    disabled: Boolean,
  },
  setup(props) {
    const { label } = props
    const { dispatch } = emtter()
    const active = inject('active')
    dispatch('props', props)

    const isShow = computed(() => {
      return active.label === label
    })

    return {
      isShow,
    }
  },
}
</script>

Tabs

<template>
  <div class="x-tabs">
    <div class="x-tabs-nav">
      <div
        class="x-tabs-menu"
        :ref="setItemRef"
        v-for="(item, i) in nav"
        :key="i"
        @click="setTabs(item, i)"
        :class="[
          item.disabled && 'x-tabs-disabled',
          {
            active: active.index === i,
          },
        ]"
      >
        {{ item.label }}
      </div>
      <i class="x-tabs-line" :style="line"></i>
    </div>
    <div class="x-tabs-view">
      <slot></slot>
    </div>
  </div>
</template>

<script>
import { provide, reactive, onBeforeUpdate, onMounted, nextTick } from 'vue'
import emtter from '../../utils/emiter'
export default {
  name: 'Tabs',
  setup() {
    const { on } = emtter()
    const nav = reactive([])
    const active = reactive({})
    const line = reactive({})
    provide('active', active)

    on('props', (value) => {
      if (!active.label) {
        setTabs(value, 0)
      }
      nav.push(value)
    })

    const setTabs = (item, i) => {
      if (item.disabled) return
      active.label = item.label
      active.index = i

      nextTick(() => {
        const e = itemRefs[i].getBoundingClientRect()
        line.width = e.width + 'px'
        line.transform = ` translateX(${itemRefs[i].offsetLeft}px)`
      })
    }

    let itemRefs = []
    const setItemRef = (el) => {
      itemRefs.push(el)
    }
    onBeforeUpdate(() => {
      itemRefs = []
    })

    return {
      nav,
      active,
      setTabs,
      setItemRef,
      line,
    }
  },
}
</script>
<style lang="less">
.x-tabs{
  .x-tabs-nav{
    display: flex;
    border-bottom: 1px solid var(--hover-background-color);
    position: relative;

    .x-tabs-menu{
      padding: 5px 10px;
      margin-right: 10px;
      cursor: pointer;
      user-select: none;

      &.active{
        color: rgba(var(--primary));
      }
      &.x-tabs-disabled{
        cursor: not-allowed;
        color: var(--hover-background-color);
      }
    }

    .x-tabs-line{
      position: absolute;
      bottom: -1px;
      left: 0;
      height: 2px;
      display: block;
      background-color: rgba(var(--primary));
      transition: all .2s ease-in-out;
      transform: translateX();
    }
  }
  
  .x-tabs-view{
    padding: 10px;
  }
}
</style>

Tooltip

import {
  getCurrentInstance,
  onMounted,
  onUnmounted,
  toRefs,
  ref,
  watchEffect,
  nextTick,
} from 'vue'
import { on, off, remove, add } from '../../utils/dom'
import { isNull } from '../../utils/isType'

export default {
  name: 'Tooltip',
  props: {
    placement: {
      type: String,
      default: 'top',
      validator: (value) =>
        [
          'left-start',
          'left',
          'left-end',
          'top-start',
          'top',
          'top-end',
          'right-start',
          'right',
          'right-end',
          'bottom-start',
          'bottom',
          'bottom-end',
        ].includes(value),
    },
    modelValue: {
      type: Boolean,
      default: null,
    },
    width: String,
    content: [String, Number],
  },
  setup(props, { slots }) {
    const instance = getCurrentInstance()
    const { placement, content, width, modelValue } = toRefs(props)
    const isShow = ref(modelValue.value)

    function getFirstElement() {
      const slotsDefault = slots.default()
      if (!Array.isArray(slotsDefault)) return null
      let element = null
      for (let index = 0; index < slotsDefault.length; index++) {
        if (slotsDefault[index] && slotsDefault[index].type) {
          element = slotsDefault[index]
        }
      }
      return element
    }

    const tip = document.createElement('div')
    tip.className = `x-tooltip x-tooltip-${placement.value}`
    width && (tip.style.width = width.value)
    const tid = (tip.id = `x-tooltip-${instance.uid}`)

    function hide() {
      const el = document.getElementById(tid)
      if (el) {
        remove(el, 'x-tooltip-show')
        on(el, 'transitionend', none)
      }
    }

    function update() {
      const Rect = instance.proxy.$el.getBoundingClientRect()
      const el = document.getElementById(tid)
      if (!el) {
        document.body.appendChild(tip)
      }
      off(tip, 'transitionend', none)
      tip.style.display = 'block'
      const { x, y } = calcStyle(Rect, tip, placement.value)
      tip.style.top = y + 'px'
      tip.style.left = x + 'px'
    }

    function show() {
      tip && add(tip, 'x-tooltip-show')
    }

    const none = () => {
      document.getElementById(tid).style.display = 'none'
    }

    onMounted(() => {
      const el = instance.proxy.$el

      watchEffect(() => {
        tip.innerHTML = `<span>${content.value}</span>` || ''
        nextTick(update)

        if (!isNull(props.modelValue)) {
          isShow.value = modelValue.value
        }
        if (isShow.value) {
          show()
        } else {
          hide()
        }
      })

      on(el, 'mouseenter', () => {
        if (!modelValue && !modelValue.value) return
        isShow.value = true
      })
      on(el, 'mouseleave', () => {
        if (modelValue && modelValue.value) return
        isShow.value = false
      })
      on(window, 'resize', update)
    })

    onUnmounted(() => {
      const el = document.getElementById(tid)
      el && document.body.removeChild(tip)
      off(window, 'resize', update)
    })

    return () => {
      return getFirstElement()
    }
  },
}

function calcStyle(Rect, tip, key) {
  let y = document.documentElement.scrollTop
  let x = 0

  const placement = {
    'top-start': () => {
      x += Rect.x
      y += Rect.y - tip.offsetHeight
    },
    top: () => {
      x += Rect.x + (Rect.width - tip.offsetWidth) * 0.5
      y += Rect.y - tip.offsetHeight
    },
    'top-end': () => {
      x += Rect.x + Rect.width - tip.offsetWidth
      y += Rect.y - tip.offsetHeight
    },
    'left-start': () => {
      x += Rect.x - tip.offsetWidth
      y += Rect.y
    },
    left: () => {
      x += Rect.x - tip.offsetWidth
      y += Rect.y + (Rect.height - tip.offsetHeight) * 0.5
    },
    'left-end': () => {
      x += Rect.x - tip.offsetWidth
      y += Rect.y + Rect.height - tip.offsetHeight
    },
    'right-start': () => {
      x += Rect.x + Rect.width
      y += Rect.y
    },
    right: () => {
      x += Rect.x + Rect.width
      y += Rect.y + (Rect.height - tip.offsetHeight) * 0.5
    },
    'right-end': () => {
      x += Rect.x + Rect.width
      y += Rect.y + Rect.height - tip.offsetHeight
    },
    'bottom-start': () => {
      x += Rect.x
      y += Rect.y + Rect.height
    },
    bottom: () => {
      x += Rect.x + (Rect.width - tip.offsetWidth) * 0.5
      y += Rect.y + Rect.height
    },
    'bottom-end': () => {
      x += Rect.x + Rect.width - tip.offsetWidth
      y += Rect.y + Rect.height
    },
  }
  placement[key]()
  return { x, y }
}
<style lang="less">
.x-tooltip{
  position: absolute;
  --color: var(--main-text-color);
  pointer-events: none;
  z-index: 995;
  opacity: 0;
  visibility: hidden;
  transition-property: opacity,transform,visibility;
  transition-duration: .2s;
  transition-timing-function: ease;

  &>span{
    padding: 6px 10px;
    border-radius: 3px;
    background-color: var(--color);
    color: var(--main-background-color);
    display: block;
    line-height: 1.3;
  }

  &.x-tooltip-show{
    opacity: 1;
    visibility: visible;
  }


  &.x-tooltip-top, &.x-tooltip-top-start, &.x-tooltip-top-end{
    padding-bottom: 8px;
    &::after{
      content: "";
      position: absolute;
      bottom: 3px;
      border-left: 4px solid transparent;
      border-right: 4px solid transparent;
      border-top: 5px solid var(--color);
    }
  }

  &.x-tooltip-top-start, &.x-tooltip-bottom-start{
    &::after{
      left: 10px;
    }
  }
  &.x-tooltip-top, &.x-tooltip-bottom{
    &::after{
      left: 50%;
      transform: translateX(-50%);
    }
  }
  &.x-tooltip-top-end, &.x-tooltip-bottom-end{
    &::after{
      right: 10px;
    }
  }

  &.x-tooltip-bottom-start, &.x-tooltip-bottom, &.x-tooltip-bottom-end{
    padding-top: 8px;

    &::after{
      content: "";
      position: absolute;
      top: 3px;
      border-left: 4px solid transparent;
      border-right: 4px solid transparent;
      border-bottom: 5px solid var(--color);
    }
  }
  

  &.x-tooltip-left, &.x-tooltip-left-start, &.x-tooltip-left-end{
    padding-right: 8px;
    transform-origin: right center;


    &::after{
      content: "";
      position: absolute;
      right: 3px;
      border-top: 4px solid transparent;
      border-bottom: 4px solid transparent;
      border-left: 5px solid var(--color);
    }
  }
  
  &.x-tooltip-left-start, &.x-tooltip-right-start {
    &::after{
      top: 10px;
    }
  }
  &.x-tooltip-left, &.x-tooltip-right{
    &::after{
      top: 50%;
      transform: translateY(-50%);
    }
  }
  &.x-tooltip-left-end, &.x-tooltip-right-end{
    &::after{
      bottom: 10px;
    }
  }
  
  &.x-tooltip-right, &.x-tooltip-right-start, &.x-tooltip-right-end{
    padding-left: 8px;

    &::after{
      content: "";
      position: absolute;
      left: 3px;
      border-top: 4px solid transparent;
      border-bottom: 4px solid transparent;
      border-right: 5px solid var(--color);
    }
  }
  
  &[class*='x-tooltip-top']{
    transform: translateY(5px);

    &.x-tooltip-show{
      transform: translateY(0);
    }
  }
  &[class*='x-tooltip-left']{
    transform: translateX(5px);

    &.x-tooltip-show{
      transform: translateY(0);
    }
  }
  &[class*='x-tooltip-right']{
    transform: translateX(-5px);

    &.x-tooltip-show{
      transform: translateY(0);
    }
  }
  &[class*='x-tooltip-bottom']{
    transform: translateY(-5px);

    &.x-tooltip-show{
      transform: translateY(0);
    }
  }
}
</style>