Chip 是一種小巧且功能多樣的元件,經常應用於描述內容的關鍵字來標記、分類或組織資訊。除了顯示資料的功能外,也偶爾會被應用於選擇、過濾的 UI 功能上。
相較於 Chip,對我們來說更直覺的名稱應該是 Tag。Chip 這個名稱源自於 Google 的 Material Design,Google 使用這個詞來描述一種小而集中的元件,類似於一小塊資料或訊息片段。在 Material Design 指南裡提到:Chip 幫助人們輸入資訊、做出選擇、過濾內容或觸發操作。它們最適合幫助使用者更快、更輕鬆地完成當前任務。因此,我們可以簡單地區分,如果設計這個元件的目的除了標記功能外還想涵蓋更多的互動功能,那麼我們可以選用 Chip 這個名稱;如果想強調只是單純的標記功能,那麼我們可以使用 Tag 這個名稱。
如果一個 UI Library 主打的是 Material Design 風格,那麼通常在這個 Library 裡面就會選用 Chip 這個命名。
在開始實作前,我們先研究各個 UI Library 的 Chip / Tag 元件是如何設計的。
Element Plus
<template>
<ElTag type="primary">Tag 1</ElTag>
<ElTag type="success">Tag 2</ElTag>
<ElTag type="info">Tag 3</ElTag>
<ElTag type="warning">Tag 4</ElTag>
<ElTag type="danger">Tag 5</ElTag>
</template>
Element Plus 提供了五種不同的顏色樣式,在 type
這個 prop 上可以傳入 primary、success、info、warning、danger。這些顏色樣式可以幫助使用者更快速地辨識出不同的標記類型。如果想要自定義顏色,可以透過 color
這個 prop 來定義想要的標籤顏色。
除此之外,Element Plus 還提供了 closable
這個 prop,當設定為 true
時,標籤會出現一個關閉按鈕,使用者可以透過點擊關閉按鈕來移除標籤。
Vuetify
<template>
<VChip>Default</VChip>
<VChip color="primary">Primary</VChip>
<VChip color="secondary">Secondary</VChip>
<VChip color="red">Red</VChip>
<VChip color="green">Green</VChip>
</template>
Vuetify 一樣提供了幾種不同顏色的變化,使用 color
這個 prop 來設定顯示的顏色。如果想要自定義顏色,也可以在 color
這個 prop 傳入想要的色票。
另外,Vuetify 的 Chip 也支援使用 closable
這個 prop 來決定是否顯示刪除按鈕的功能。
除了上面呈現出來的以外,Element Plus 和 Vuetify 都提供了 size
這個 prop 來設定 Chip 的大小。在我們的 <AtomicChip>
也可以加入這個設計。
綜合以上並結合自身經驗,我們統整出 <AtomicChip>
的功能:
color
設定顏色。size
設定大小。deletable
設定是否顯示刪除按鈕。如果有顯示刪除按鈕的話,我們可以透過 @delete
事件來接收刪除按鈕的點擊事件。
雖然我們參考的 UI Library 決定是否顯示刪除按鈕用的都是
closable
這個 prop,但是我自己感覺deletable
這個 prop 更直覺,所以選擇使用deletable
這個名稱。
使用結構如下:
<template>
<AtomicChip
variant="contained"
color="primary"
size="medium"
/>
</template>
首先,我們將需求中提到的功能整理成 props
與 emit
的介面,我們會需要下列屬性:
Props
名稱 | 型別 | 預設值 | 說明 |
---|---|---|---|
variant |
contained , outlined , text |
contained |
Chip 的樣式 |
color |
primary , success , warning , danger , info |
primary |
Chip 的顏色 |
size |
small , medium |
medium |
Chip 的大小 |
deletable |
boolean |
false |
是否顯示刪除按鈕 |
Emits
名稱 | 型別 | 說明 |
---|---|---|
delete |
(event: Event): void | 點擊刪除 Chip 按鈕 |
interface AtomicChipProps {
variant?: 'contained' | 'outlined' | 'text';
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
size?: 'medium' | 'small';
deletable?: boolean;
}
interface AtomicChipEmits {
(event: 'delete', value: Event): void;
}
const props = withDefaults(defineProps<AtomicChipProps>(), {
variant: 'contained',
color: 'primary',
size: 'medium',
onDelete: undefined,
});
const emit = defineEmits<AtomicChipEmits>();
首先我們來規劃模板的部分:
<template>
<span
class="atomic-chip"
:class="rootClass"
>
<span>
<slot name="default" />
</span>
<template v-if="deletable">
<button
aria-label="Delete"
type="button"
@click="emit('delete', $event)"
>
<CloseSvg
fill="currentColor"
height="16"
width="16"
/>
</button>
</template>
</span>
</template>
接著整理 rootClass
後我們就完成了 <AtomicChip>
這個元件。
const BASIC_CLASS = 'atomic-chip';
const rootClass = computed(() => [
`${BASIC_CLASS}--${props.size}`,
`${BASIC_CLASS}--${props.color}`,
`${BASIC_CLASS}--${props.variant}`,
]);
CSS 部分我們聚焦在 color
與 variant
拼湊出來的組合。
這裡的作法與之前在 <AtomicButton>
裡面在處理 color
跟 variant
的作法有些不同。在這裡我們使用 CSS 變數來設定顏色變化,這樣的好處是我們可以得到更小的 CSS 大小。
.atomic-chip {
// color
@each $color, $value in $color-map {
&--#{$color} {
--chip-color: #{$value};
--chip-color-second: #{rgba($value, 0.1)};
}
}
// variant
&--contained {
color: var(--chip-color);
background-color: var(--chip-color-second);
}
&--outlined {
color: var(--chip-color);
border-color: var(--chip-color);
}
&--text {
color: var(--chip-color);
}
}
這樣一來我們就完成了 <AtomicChip>
這個元件的實作。
color
接受傳入色票除了預設的顏色外,我們也很常見到一些客製化的色票使用,這經常出現在可以自定義標籤的場景,如果我們想要支援 color
可以自定義顏色,我們可以如何實作呢?
色碼的表示方式很多種,為了不要元件過度複雜,但有保有自由定義顏色的彈性,因此我們的 <AtomicChip>
的 color
僅支援 HEX 格式的色碼。
首先我們先檢查開發人員傳入的色碼是否為我們預設可以接受的色碼,如果是才把對應的 CSS 變數設定上去。
const THEME_COLORS = ['primary', 'success', 'warning', 'danger', 'info'];
const isThemeColor = computed(() => {
return THEME_COLORS.includes(props.color);
});
const BASIC_CLASS = 'atomic-chip';
const rootClass = computed(() => [
`${BASIC_CLASS}--${props.size}`,
`${BASIC_CLASS}--${props.variant}`,
isThemeColor.value ? `${BASIC_CLASS}--${props.color}` : null,
]);
接著我們把開發人員傳入的顏色色碼用 style
的方式設定上去。
const rootStyle = computed(() =>
!isThemeColor.value
? {
'--chip-color': props.color,
}
: null,
);
不過在這裡遇到一個困難,前面我們在 CSS 中用了兩個變數,一個是 --chip-color
另一個是 --chip-color-second
。--chip-color-second
的值是 --chip-color
的 10% 透明度。
如果開發人員傳入的色碼為 #009999
我們要如何得到這個色碼的 10% 透明度呢?
轉換為 HEX + A(透明度)格式
在 HEX + A 色碼中,透明度的值是 00
到 FF
之間的數字,我們可以把這個數字轉換為 10 進位的數字,0
到 1
之間。
我們知道 FF
是 255
(16^2 - 1
),所以我們可以把透明度的值乘上 255
再轉換為 16 進位的數字。
在 CSS 中我們的透明度是 0.1
,所以我們可以得到 0.1 * 255 = 25.5
,將 25.5
四捨五入到最接近的整數後轉換為 16 進位的數字就是 1A
。
因此我們只要在開發人員傳入的色碼後面加上 1A
就可以得到透明度的色碼了。
#009999 -> #0099991A
因此我們的 rootStyle
可以擴充如下:
const opacity = Math.round(0.1 * 255).toString(16)
const rootStyle = computed(() =>
!isThemeColor.value
? {
'--chip-color': props.color,
'--chip-color-second': `${props.color}${opacity}`,
}
: null,
);
但是,如果遇到開發人員傳入的是三位數的色碼,這個方法就行不通了。
#099 -> #0999A(不是正確的色碼)
因此當遇到三位數的色碼,我們需要先把色碼轉換為六位數的色碼,以下方法可以將色碼展開為六位數的色碼。
function toExpandedHex (color: string): string {
const match = color.match(/[a-f0-9]{6}|[a-f0-9]{3}/i);
if (!match) return '#FFFFFF';
let colorString = match[0];
if (colorString.length === 3) {
colorString = colorString
.split('')
.map(char => char + char)
.join('');
}
return `#${colorString}`;
}
再調整一下 rootStyle
:
const opacity = Math.round(0.1 * 255).toString(16)
const rootStyle = computed(() =>
!isThemeColor.value
? {
'--chip-color': props.color,
'--chip-color-second': `${toExpandedHex(props.color)}${opacity}`,
}
: null,
);
這樣我們就可以自定義 <AtomicChip>
的顏色了。
並加到模板上後,我們就可以得到一個允許自定義的 <AtomicChip>
元件了。
<template>
<span
class="atomic-chip"
:class="rootClass"
:style="rootStyle"
>
<!-- 略 -->
</span>
</template>
不過型別定義部分就會比較困難了。
interface AtomicChipProps {
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | string;
}
這樣在使用這個元件時,除了知道 color
接受的是一個字串外,其他的資訊就會失去。這是因為 primary
涵蓋在 string 這個型別裡面,不經過特殊處理的話開發人員就無法透過 TypeScript 的型別提示得知有哪些屬性可以選擇。
提供一個有效的解決方法:
type LiteralUnion<T> = T | (string & {});
interface AtomicChipProps {
color?: LiteralUnion<'primary' | 'success' | 'warning' | 'danger' | 'info'>;
}
這樣在開發上,開發人員除了可以保有 primary
、success
、warning
、danger
、info
這幾個選項外,也可以輸入任意字串自定義自己想要的顏色。
但因為這不是我們這次主要討論的重點,所以我們就不深入探究這個問題了。有興趣的話,這裡附上 GitHub Issue 討論的連結:Literal String Union Autocomplete
在介紹元件時我們提到 Chip 這個元件除了可以用來標記外,也可以用來做一些互動功能。為了能夠對渲染出正確的 HTML 標籤,我們可以新增一個 as
的 prop 來決定渲染出什麼樣的 HTML 標籤。
interface AtomicChipProps {
as?: any;
// 略
};
const props = withDefaults(defineProps<AtomicChipProps>(), {
as: 'span',
// 略
});
並且在模板中我們使用 Vue 內建元素 <component>
來動態渲染元素。
<template>
<component
:is="props.as"
class="atomic-chip"
:class="rootClass"
:style="rootStyle"
>
<!-- 略 -->
</component>
</template>
這樣我們就可以隨著使用的情境來不同地使用 HTML 元素甚至是元件了。
<template>
<AtomicChip
as="AtomicLink"
href="https://www.google.com"
>
Google
</AtomicChip>
</template>
這次我們實作了一個 <AtomicChip>
元件,元件本身實作非常簡單單純,我們也討論了命名上的選用因素。在建立自己的元件庫時,除了很直覺的 Tag 之外,我們也多了一個可以思考的方向。另外,在進階需求中我們也嘗試讓 color
可以支援自定義的顏色,並讓型別支援更加彈性。
最後,我們也讓元件可以隨著使用情境不同,開發人員可以自設定要使用的 HTML 標籤或元件,讓元件在應用上更加彈性。
<AtomicChip>
原始碼:AtomicChip.vue