iT邦幫忙

2024 iThome 鐵人賽

DAY 17
2
Modern Web

為你自己寫 Vue Component系列 第 17

[為你自己寫 Vue Component] AtomicTooltip

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicTooltip

Tooltip 是一個用來顯示、說明目標元件相關訊息的元件,通常用於滑鼠停留於元件或透過鍵盤聚焦時顯示。像是在 LINE Design System 中提到,他們會使用 Tooltip 顯示特定功能或更新服務的說明,也可以幫助使用者理解無法直接從 UI 界面中識別的功能,除此之外也會用於促銷活動、附加描述等。

AtomicTooltip

元件分析

元件架構

AtomicTooltip 架構圖

  1. Content:Tooltip 的內容。
  2. Arrow:Tooltip 的箭頭,用來指向目標元件。
  3. Reference:Tooltip 的參考元件,當滑鼠懸停或是鍵盤聚焦時會顯示 Tooltip。

功能設計

在開始實作前,我們先研究各個 UI Library 的 Tooltip 元件是如何設計的。

Element Plus

Element Plus Tooltip

<template>
  <ElTooltip
    class="box-item"
    effect="dark"
    content="Bottom Center prompts info"
    placement="bottom"
  >
    <ElButton>bottom</ElButton>
  </ElTooltip>
</template>

Element Plus 的 <ElTooltip> 元件提供了 contentplacementeffect 等 props,可以設定 Tooltip 的內容、位置、顏色等。

Vuetify

Vuetify Tooltip

<template>
  <VTooltip location="end">
    <template v-slot:activator="{ props }">
      <VBtn v-bind="props">
        End
      </VBtn>
    </template>
    Tooltip
  </VTooltip>
</template>

Vuetify 的使用有多種組合,其中一種與我們現行採用的設計比較接近,但如果採用上面範例的作法,則需要從 activator slot 接收 props 並且綁定到需要觸發 Tooltip 的元件上。

Nuxt UI

Nuxt UI Tooltip

<template>
  <UTooltip :shortcuts="['⌘', 'O']" :popper="{ arrow: true }">
    <UButton color="gray" label="Hover me" />
    <template #text>
      Tooltip example 2
    </template>
  </UTooltip>
</template>

在 Nuxt UI 的範例中,我們可以看到它使用了 text slot 來設定 Tooltip 的內容,如果要呈現一些比較複雜的內容,單用 props 處理可能會讓事情更複雜,這時使用 slot 就會是更好的選擇。

不過,也不是說這裡沒有提到 Element Plus 的 slot 範例或是 Nuxt UI 的 props 範例就表示他們不支援相關的功能喔!

綜合以上並結合自身經驗,我們統整出 <AtomicTooltip> 的功能:

  • 可以透過 placement 設定 Tooltip 出現的位置。
  • 可以透過 content 或是 content slot 設定 Tooltip 的內容。

另外如同在 <AtomicPopover> 提到的,<AtomicTooltip> 會基於 <AtomicPopover> 來實作,所以一些在 <AtomicPopover> 中的功能也可以讓使用者在開發時自行定義。

  • 可以透過 trigger 設定 Tooltip 的觸發方式。
  • 可以透過 offset 設定 Tooltip 與目標元件的距離。

使用結構如下:

<template>
  <AtomicTooltip
    content="喚醒心中最強大的鐵人!"
    :offset="offset"
    :placement="placement"
    :trigger="trigger"
  >
    <InfoSvg />
  </AtomicTooltip>
</template>

這樣看起來 <AtomicTooltip> 功能其實非常簡單,實作上應該不會太複雜。不過這也歸功於前面我們已經有一起實作了 <AtomicPopover> 元件,所以在這裡的實作上我們也只需要基於 <AtomicPopover> 並且處理 UI 的部分即可。

元件實作

首先,我們將需求中提到的功能整理成 propsemit 的介面,我們會需要下列屬性:

Props

名稱 型別 預設值 說明
placement top, right, bottom, left, top-start, top-end, right-start, right-end, bottom-start, bottom-end, left-start, left-end bottom 設定 Tooltip 出現的位置
trigger MaybeArray<click | hover | focus | touch> ['hover', 'focus'] 設定 Tooltip 的觸發方式
offset string 8 設定 Tooltip 與目標元件的距離
content string undefined 設定 Tooltip 的內容

Slots

名稱 說明
default 設定 Tooltip 的 Reference
content 設定 Tooltip 的內容
import type { ComponentProps } from 'vue-component-type-helpers';

type AtomicPopoverProps = ComponentProps<typeof AtomicPopover>;

interface AtomicTooltipProps {
  content?: string;
  trigger?: AtomicPopoverProps['trigger']
  placement?: AtomicPopoverProps['placement'];
  offset?: AtomicPopoverProps['offset'];
  disabled?: boolean
}

interface AtomicPopoverSlots {
  default?: () => ReturnType<Slot>;
  content?: () => ReturnType<Slot>;
}

withDefaults(defineProps<AtomicTooltipProps>(), {
  content: undefined,
  trigger: () => ['hover', 'focus'],
  placement: 'bottom-start',
  offset: 8,
});

const slots = defineSlots<AtomicPopoverSlots>();

幸運的是,因為我們前面已經費盡千辛萬苦做出了 <AtomicPopover>,所以我們在這裡不需要處理太複雜的程式邏輯,我們只要把這些收到的 props 傳給 <AtomicPopover> 並且處理 UI 的部分即可。

<template>
  <AtomicPopover
    :disabled="disabled"
    :offset="offset"
    :placement="placement"
    :trigger="trigger"
  >
    <template #reference>
      <slot name="default" />
    </template>

    <template
      v-if="content || slots.content"
      #default
    >
      <div class="atomic-tooltip">
        <slot name="content">
          {{ content }}
        </slot>
      </div>
    </template>
  </AtomicPopover>
</template>
<style lang="scss">
.atomic-tooltip {
  padding: 6px 10px;
  border-radius: 4px;
  font-size: 0.875rem;
  background-color: #232323;
  color: white;
}
</style>

太棒了!我們已經完成了 <AtomicTooltip> 元件基本的樣式。

AtomicTooltip Demo

不過好像少了一點東西?箭頭!

畫出箭頭

我們先看看如何畫出箭頭,首先我們可以先用 <span> 畫一個正方形:

<span class="atomic-tooltip__arrow"></span>

這裡先故意上一個黃色方便辨識。

.atomic-tooltip {
  &__arrow {
    width: 14px;
    height: 14px;
    background-color: yellow;
  }
}

AtomicTooltip Arrow Setp1

接著我們使用偽元素 ::before 來畫一個小一點的正方形。

這個小一點的正方形的對角線長度要剛好等於大正方形的邊長。根據等腰直角三角形的公式,斜邊與對邊(臨邊)長比例為:1 : 1 : √2,我們可以算出小正方形的寬度約為 10px

.atomic-tooltip {
  &__arrow {
    width: 14px;
    height: 14px;
    background-color: yellow;

    &::before {
      content: '';
      position: absolute;
      width: 10px;
      height: 10px;
      background-color: #232323;
    }
  }
}

AtomicTooltip Arrow Setp2

將旋轉的基準點調整到左上角,並逆時針旋轉 45 度。

.atomic-tooltip {
  &__arrow {
    width: 14px;
    height: 14px;
    background-color: yellow;

    &::before {
      content: '';
      position: absolute;
      top: 0;
      left: 0;
      width: 10px;
      height: 10px;
      transform: rotate(-45deg);
      transform-origin: top left;
      background-color: #232323;
    }
  }
}

AtomicTooltip Arrow Setp3

最後我們將超出黃色正方形的部分隱藏起來並把黃色底色移除,就完成了我們的三角形。

.atomic-tooltip {
  &__arrow {
    width: 14px;
    height: 14px;
    overflow: hidden;

    &::before {
      content: '';
      position: absolute;
      top: 0;
      left: 0;
      width: 10px;
      height: 10px;
      transform: rotate(-45deg);
      transform-origin: top left;
      background-color: #232323;
    }
  }
}

AtomicTooltip Arrow Setp4

但現在的箭頭不會定位到我們希望的位置,關於算定位的事情,我們交給 <AtomicPopover> 來處理。

箭頭定位

讓我們把焦點切換到 <AtomicPopover>,在 <AtomicPopover> 的實作中我們一步一步的計算了 Popover 的位置並考量了偏移的情況,最後為了更好地覆蓋一些 Edge Case,我們選用了 Floating UI 這個強大的 JavaScript Library 來幫助我們計算 Popover 的位置。

import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue';

const { floatingStyles } = useFloating(referenceRef, popoverRef, {
  open: modelValueWritable,
  placement: () => props.placement,
  whileElementsMounted: autoUpdate,
  middleware: () => [
    offset(props.offset), 
    flip(), 
    shift()
  ],
});

剛好 Floating UI 也可以幫我們計算 arrow 的定位,使用 Floating UI 提供的 arrow middleware,我們只要提供 Arrow 的元素,它就會幫我們算好箭頭的定位。

interface AtomicPopoverProps {
  arrow?: {
    element: HTMLElement | null;
    padding?: number;
  };
}
import { arrow, autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue';

const {
  floatingStyles,
  middlewareData,
  placement,
} = useFloating(referenceRef, popoverRef, {
  open: modelValueWritable,
  placement: () => props.placement,
  whileElementsMounted: autoUpdate,
  middleware: () => [
    offset(props.offset),
    flip(),
    shift(),

    // 加入 Arrow 的 middleware
    arrow(props.arrow),
  ],
});

arrow 設定方式有兩種,一種是 Arrow 實作在 Popover 裡面,另外一種是繼承的元件各自實作並透過 props 傳入。

這兩種方式都是可行的,這裡選擇的是從 props 傳入,好處是 <AtomicTooltip> 以及其他繼承自 <AtomicPopover> 的元件都會比較好管理自己的箭頭樣式與設定,但缺點則是繼承 <AtomicPopover> 的元件想要有箭頭的 UI 都必須自己處理。

但我們還是會在 <AtomicPopover> 中計算 Arrow 的定位資訊。

const arrowStyle = computed(() => {
  const { arrow } = middlewareData.value;
  if (!arrow) return arrow;

  // top、right、bottom、left
  const [side] = placement.value.split('-') as [Side];

  const arrowX = arrow.x ? `${arrow.x}px` : '';
  const arrowY = arrow.y ? `${arrow.y}px` : '';

  return {
    position: 'absolute',
    left: arrowX,
    top: arrowY,
    [side]: '100%',
  };
});

AtomicTooltip Arrow Setp5

把定位加到箭頭上後發現還有一些偏差,除了設定 top-* 的以外都需要再轉個角度看起來才正常。

const arrowStyle = computed(() => {
  // 略

  // top、right、bottom、left
  const [side] = placement.value.split('-') as [Side];

  // 略

  // 定位後需要轉角度
  const rotation = {
    top: '',
    left: 'rotate(-90deg)',
    bottom: 'rotate(180deg)',
    right: 'rotate(90deg)',
  }[side];

  return {
    position: 'absolute',
    left: arrowX,
    top: arrowY,
    [side]: '100%',
    transform: rotation
  };
});

這樣我們就可以看到正常的箭頭了!

AtomicTooltip Arrow Setp6

不過這裡應該要怎麼把 <AtomicPopover> 算出來的箭頭定位資訊傳給 <AtomicTooltip> 呢?我們可以使用 Scoped Slots 來傳遞資訊。

Scoped Slot

所以我們只要在 Popover 的 Slot 傳入剛剛算出來的箭頭資訊,我們就能在 <AtomicTooltip> 裡面取用它。

我們在 <slot> 這裡將 arrowStyle 傳出去。

<div
  :id="id"
  ref="popoverRef"
  class="atomic-popover"
  :role="role"
  :style="floatingStyles"
>
  <slot
    :arrow-style="arrowStyle"
    :close="close"
    name="default"
  />
</div>

<AtomicTooltip> 中我們可以透過 v-slot 取得 Arrow 的資訊。

<template>
  <AtomicPopover
    :arrow="{
      element: arrowRef,
      padding: 4,
    }"
    :disabled="disabled"
    :offset="offset"
    :placement="placement"
  >
    <!-- 略 -->

    <template #default="{ arrowStyle }">
      <div class="atomic-tooltip">
        <slot name="content">
          {{ content }}
        </slot>
        <span
          ref="arrowRef"
          class="atomic-tooltip__arrow"
          :style="arrowStyle"
        />
      </div>
    </template>
  </AtomicPopover>
</template>

這樣我們的 <AtomicTooltip> 就完成了!

無障礙

<AtomicPopover> 中,我們已經為這類彈出式元件加上了一些基本的無障礙支援,因此 <AtomicTooltip> 中我們只要確保使用輔助技術的使用者知道出現在畫面上的這個 UI 是一個 Tooltip 即可。

<template>
  <AtomicPopover role="tooltip">
    <!-- 略 -->
  </AtomicPopover>
</template>

不過我們的 <AtomicPopover> 目前還不接受 role 的 props,所以我們需要在 <AtomicPopover> 中加入 role 的 props,並且將這個 role 放到裡面的 Popover 元素上。

<div
  ref="popoverRef"
  class="atomic-popover"
  :role="role"
  :style="floatingStyles"
>
  <slot
    :arrow-style="arrowStyle"
    :close="close"
    name="default"
  />
</div>

總結

<AtomicTooltip> 裡面不論是定位(placement)、觸發方式(trigger)還是偏移(offset),在 <AtomicPopover> 中都已經有處理過了,所以在這裡我們只需要處理 UI 的部分即可。

如果想為 <AtomicTooltip> 加上箭頭,我們可以透過 Floating UI 提供的 arrow middleware 來幫助我們計算箭頭的位置,經過整理後我們就可以透過 Scoped Slots 將箭頭的定位資訊傳遞給 <AtomicTooltip>

最後無障礙的部分,因為大部分的無障礙支援已經在 <AtomicPopover> 中處理過了,所以在這裡我們只需要確保使用輔助技術的使用者知道出現在畫面上的這個 UI 是一個 Tooltip 就完成了。

參考資料


上一篇
[為你自己寫 Vue Component] AtomicTable
下一篇
[為你自己寫 Vue Component] AtomicModal
系列文
為你自己寫 Vue Component19
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言