![[為你自己寫 Vue Component] AtomicTooltip](https://ithelp.ithome.com.tw/upload/images/20240924/20120484Fe1vcnqHFh.png)
Tooltip 是一個用來顯示、說明目標元件相關訊息的元件,通常用於滑鼠停留於元件或透過鍵盤聚焦時顯示。像是在 LINE Design System 中提到,他們會使用 Tooltip 顯示特定功能或更新服務的說明,也可以幫助使用者理解無法直接從 UI 界面中識別的功能,除此之外也會用於促銷活動、附加描述等。


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

<template>
  <ElTooltip
    class="box-item"
    effect="dark"
    content="Bottom Center prompts info"
    placement="bottom"
  >
    <ElButton>bottom</ElButton>
  </ElTooltip>
</template>
Element Plus 的 <ElTooltip> 元件提供了 content、placement、effect 等 props,可以設定 Tooltip 的內容、位置、顏色等。
Vuetify

<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

<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 的部分即可。
首先,我們將需求中提到的功能整理成 props 與 emit 的介面,我們會需要下列屬性:
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> 元件基本的樣式。

不過好像少了一點東西?箭頭!
我們先看看如何畫出箭頭,首先我們可以先用 <span> 畫一個正方形:
<span class="atomic-tooltip__arrow"></span>
這裡先故意上一個黃色方便辨識。
.atomic-tooltip {
  &__arrow {
    width: 14px;
    height: 14px;
    background-color: yellow;
  }
}

接著我們使用偽元素 ::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;
    }
  }
}

將旋轉的基準點調整到左上角,並逆時針旋轉 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;
    }
  }
}

最後我們將超出黃色正方形的部分隱藏起來並把黃色底色移除,就完成了我們的三角形。
.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;
    }
  }
}

但現在的箭頭不會定位到我們希望的位置,關於算定位的事情,我們交給 <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%',
  };
});

把定位加到箭頭上後發現還有一些偏差,除了設定 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
  };
});
這樣我們就可以看到正常的箭頭了!

不過這裡應該要怎麼把 <AtomicPopover> 算出來的箭頭定位資訊傳給 <AtomicTooltip> 呢?我們可以使用 Scoped Slots 來傳遞資訊。
所以我們只要在 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 就完成了。
<AtomicTooltip> 原始碼:AtomicTooltip.vue
<AtomicPopover> 實作回顧:[為你自己寫 Vue Component] AtomicPopover