![[為你自己寫 Vue Component] AtomicBadge](https://ithelp.ithome.com.tw/upload/images/20240919/20120484XyNcUWZXuF.png)
Badge 元件是一種簡單但功能強大的資料展示元件,通常以小圓形的形式出現,依附於其他 UI 元件上。Badge 元件主要用於通知提醒,顯示未讀訊息、通知數量,或是狀態指示如上線中、離線、忙碌等。


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

<template>
  <ElBadge :value="12" class="item">
    <ElButton>Button</ElButton>
  </ElBadge>
  <ElBadge :value="99" :max="9" class="item">
    <ElButton>Button</ElButton>
  </ElBadge>
  <ElBadge :value="1" class="item" is-dot>
    <ElButton>Button</ElButton>
  </ElBadge>
  <ElBadge :value="2" class="item" type="warning">
    <ElButton>Button</ElButton>
  </ElBadge>
  <ElBadge :value="1" class="item" color="green">
    <ElButton>Button</ElButton>
  </ElBadge>
</template>
Element Plus 選用了 color 及 type 來設定 Badge 的樣式,value 用來設定 Badge 的內容,如果希望只顯示的是圓點可以設定 is-dot,最後 max 可以設定顯示數字的最大值,如果設定為 9,當 value 超過這個數值則只會顯示 9+。
color 及 type 在這裡設定的都是 Badge 的顏色,不過看起來 color 的權重更高一點,並且可以傳入任何 CSS 顏色值。
Vuetify

<template>
  <VBtn class="text-none" stacked>
    <VBadge color="success" dot>
      <VIcon>mdi-home-outline</VIcon>
    </VBadge>
  </VBtn>
  <VBtn class="text-none" stacked>
    <VBadge color="error" content="200" max="9">
      <VIcon>mdi-store-outline</VIcon>
    </VBadge>
  </VBtn>
  <VBtn class="text-none" stacked>
    <VBadge color="error" content="2">
      <VIcon>mdi-bell-outline</VIcon>
    </VBadge>
  </VBtn>
</template>
Vuetify 的 Badge 元件也是以 color 來設定 Badge 的顏色,content 用來設定 Badge 的內容,而圓點樣式可以使用 dot 來開啟,一樣支援 max 設定。
Nuxt UI
<template>
  <UChip size="2xl">
    <UButton icon="i-heroicons-inbox" color="gray" />
  </UChip>
</template>
在 Nuxt UI 中,這個元件的名稱叫做 <UChip>,除了名稱之外其他部分與 Element Plus 跟 Vuetify 都差不多。
另外,Nuxt UI 可以透過 position 來設定 Badge 的位置,Vuetify 也可以透過 location 設定,而 Element Plus 從目前的文件上看起來尚未支援定位的功能。
綜合以上並結合自身經驗,我們統整出 <AtomicBadge> 的功能:
content 設定 Badge 要顯示的內容。max 設定最大顯示數字。placement 設定 Badge 的位置,支援 top-left、top-right、bottom-left、bottom-right 四個位置。size 設定 Badge 的大小,這裡除了 medium 與 large 外,也將 dot 整合在這個設定裡面。color 設定 Badge 的顏色。showZero 決定當 content 為 0 時是否顯示 Badge。使用結構如下:
<template>
  <AtomicBadge
    color="primary"
    content="12"
    max="9"
    placement="top-right"
    size="medium"
  >
    <AtomicAvatar size="60" />
  </AtomicBadge>
</template>
首先,我們將需求中提到的功能整理成 props 的介面,我們會需要下列屬性:
| 名稱 | 型別 | 預設值 | 說明 | 
|---|---|---|---|
| content | string | undefined | Badge 的內容 | 
| max | number | 99 | 最大顯示數字 | 
| placement | top-left,top-right,bottom-left,bottom-right | top-right | Badge 的位置 | 
| size | medium,large,dot | medium | Badge 的大小 | 
| color | primary,success,warning,danger,info | primary | Badge 的顏色 | 
| showZero | boolean | false | 是否顯示 0 的 Badge | 
type Numberish = number | `${number}`;
interface AtomicBadgeProps {
  content?: string | number | null;
  max?: Numberish;
  placement?:
    | 'top-left'
    | 'top-right'
    | 'bottom-left'
    | 'bottom-right';
  size?: 'dot' | 'medium' | 'large';
  color?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
  showZero?: boolean;
}
const props = withDefaults(defineProps<AtomicBadgeProps>(), {
  content: undefined,
  max: 99,
  placement: 'top-right',
  size: 'medium',
  color: 'danger',
  showZero: false,
});
首先我們來規劃元件的模板
<template>
  <span class="atomic-badge">
    <slot name="default" />
    <span class="atomic-badge__content">
      {{ content }}
    </span>
  </span>
</template>
Content 的部分我們要加上顯示判斷
content 為 null 或 undefined 則不顯示。content 為數字,要檢查有沒有超出 max 的值,如果超出則顯示 {max}+。size 為 dot 則不顯示 content。<span class="atomic-badge__content">
  <template v-if="!isNullOrUndefined(content) && size !== 'dot'">
    {{ Number(content) > Number(max) ? `${max}+` : content }}
  </template>
</span>
接著我們來處理 Badge 的樣式。關於樣式有 Badge 的位置、大小、顏色要處理。
const invisible = computed(() => {
  const { content, showZero } = props;
  return !showZero && Number(content) === 0;
});
const CONTENT_CLASS = 'atomic-badge__content';
const contentClass = computed(() =>
  [
    `${CONTENT_CLASS}--${props.placement}`,
    `${CONTENT_CLASS}--${props.size}`,
    `${CONTENT_CLASS}--${props.color}`,
    invisible.value ? `${CONTENT_CLASS}--invisible` : '',
  ].join(' ')
);
<template>
  <span class="atomic-badge">
    <slot name="default" />
    <span
      class="atomic-badge__content"
      :class="contentClass"
    >
      <!-- 略 -->
    </span>
  </span>
</template>
大小跟顏色的部份我們應該都很熟悉了!這裡主要要處理的是 Badge 的位置。
基本樣式
.atomic-badge {
  position: relative;
  &__content {
    position: absolute;
  }
}
.atomic-badge {
  &__content {
    &--top-right {
      top: 0;
      right: 0;
      transform: translateX(50%) translateY(-50%);
    }
  }
}
但還有一個要在這裡一併處理的是 showZero 的情況,在這裡為了讓 content 從 0 到 1 的過程中有縮放效果,我們可選用 transform: scale(0) 到 transform: scale(1) 的方式來處理。
所以原本的樣式就要稍微擴充一下:
.atomic-badge {
  position: relative;
  &__content {
    position: absolute;
    &--top-right {
      top: 0;
      right: 0;
      transform: translateX(50%) translateY(-50%) scale(1);
    }
    &--top-right#{&}--invisible {
      transform: translateX(50%) translateY(-50%) scale(0);
    }
  }
}
不多,我們只要重複四次就好了?!
如果跟我一樣覺得這樣很冗長很煩的話,可以參考看看這個受 Tailwind CSS 啟發的作法,我們先新增一個 %transform 的樣式
%transform {
  transform: translateX(var(--badge-translate-x)) translateY(var(--badge-translate-y))
    scale(var(--badge-scale));
}
再新增 %top 與 %right 的樣式
%top {
  --badge-translate-y: -50%;
  top: 0;
}
%right {
  --badge-translate-x: 50%;
  right: 0;
}
整合起來會像是這樣:
.atomic-badge {
  position: relative;
  &__content {
    --badge-scale: 1;
    position: absolute;
    @extend %transform;
    &--invisible {
      --badge-scale: 0;
    }
    &--top-right {
      @extend %top;
      @extend %right;
    }
    &--top-left {
      @extend %top;
      @extend %left;
    }
  }
}
這樣看起來就簡潔許多了呢!
在現在的版本中,我們的 <AtomicBadge> 遇到圓形的 UI 會離邊界有一點距離

如果能支援貼到邊上的話就太好了!要做到這個功能我們得拿出紙筆算一下數學,我們要算出 <AtomicBadge> 遇到圓形的時候,top 與 right 要偏移的百分比。

第一步,假設圓的直徑為 2,我們得先算出紅色三角形的斜邊長。
在這裡的紅色三角形是一個「等腰直角三角形」,所以斜邊長與對邊(臨邊)長為 1 : 1 : √2,所以斜邊長為 2√2。
第二步,斜邊長減掉圓的直徑 2 並除以 2,就會算出虛線方形的對角線長度

第三步,根據第一步提到的 1 : 1 : √2 這個比例我們知道,當我們將上一步的結果除以 √2 就會得到 top 與 right 的值。

再來,√2 約等於 1.414,所以我們可以算出

一開始我們的圓直徑是 2,所以我們要將這個值除以 2 乘以 100 就會得到 top 與 right 要偏移的百分比。

所以我們可以算出來,如果要讓 <AtomicBadge> 貼到圓形邊上,top 與 right 要偏移的百分比為 14.65%。
我們新增一個 props 叫 overlap,讓使用者決定要使用圓形還是方形定位的 Badge。
interface AtomicBadgeProps {
  overlap?: 'circular' | 'rectangular';
}
const props = withDefaults(defineProps<AtomicBadgeProps>(), {
  overlap: 'circular',
});
const CONTENT_CLASS = 'atomic-badge__content';
const contentClass = computed(() =>
  [
    `${CONTENT_CLASS}--${props.overlap}`,
    // 略
  ].join(' ')
);
.atomic-badge {
  position: relative;
  &__content {
    // 略
    &--rectangular {
      --badge-offset: 0;
    }
    &--circular {
      --badge-offset: 14.65%;
    }
  }
}
%top {
  --badge-translate-y: -50%;
  top: var(--badge-offset);
}
%right {
  --badge-translate-x: 50%;
  right: var(--badge-offset);
}
這樣我們就可以讓使用者在遇到圓形的時候,可以選擇要貼到邊上還是留一點距離。
這次我們實作了一個 <AtomicBadge> 元件,這個元件可以讓我們在 UI 上加上一個小小的提示,讓使用者知道這個元件有一些特殊的狀態。
在實作元件定位的過程中我們借鑒了 Tailwind CSS 處理 Util Class 的作法,免去了我們不斷定義重複樣式的麻煩,這樣的作法讓我們的樣式更加簡潔。
最後我們也實作了一個進階需求,讓使用者可以選擇要讓 <AtomicBadge> 貼到圓形邊上還是留一點距離,過程中用了基礎的幾何計算,強烈建議遇到計算問題實際拿紙筆算一次或是自己畫圖畫一次,這樣更能深刻吸收並且應用在其他地方。
<AtomicBadge> 原始碼:AtomicBadge.vue