![[為你自己寫 Vue Component] AtomicRating](https://ithelp.ithome.com.tw/upload/images/20241005/20120484eqRV6x7nei.png)
Rating 元件讓使用者可以對某項目進行評分,通常以星星或其他符號來表示評分等級。Rating 元件的核心功能是提供一種直觀的方式,讓使用者針對產品、服務或內容表達他們的滿意度或偏好。它可以是靜態顯示使用者的評分,也可以是可互動的,讓使用者自行選擇評分。

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

<template>
<ElRate
v-model="value1"
:max="5"
size="large"
/>
</template>
Element Plus 的 <ElRate> 元件可以使用 v-model 來雙向綁定評分值,並且可以設定 max 來設定最大值,size 來設定元件大小。
除此之外,Element Plus 也實現了鍵盤操作功能,使用者可以使用鍵盤上的左右鍵來增減評分值。

Vuetify

<template>
<VRating
v-model="value"
active-color="primary"
hover
:length="5"
:size="34"
/>
</template>
Vuetify 的 <VRating> 元件也可以使用 v-model 來雙向綁定評分值,並且可以設定 active-color 來設定選取的顏色,length 來設定最大值,size 來設定元件大小。
另外,<VRating> 預設並不具備滑鼠懸停在某個評分上的 feedback 的效果,如果需要可以使用 hover 這個 prop 來啟用。
PrimeVue

<template>
<Rating v-model="value" :stars="5" />
</template>
PrimeVue 的 <Rating> 元件也可以使用 v-model 來雙向綁定評分值,並且可以設定 stars 來設定最大值。
PrimeVue 在鍵盤操作上也有很好的支援,使用者可以使用鍵盤上的左右鍵來增減評分的值。

綜合以上並結合自身經驗,我們統整出 <AtomicRating> 的功能:
使用結構如下:
v-model 來雙向綁定評分的值。max 來設定 Rating 的最大值。size 來設定 Rating 元件的大小。disabled 來禁用評分。readonly 來設定唯讀模式。<template>
<AtomicRating
v-model="rating"
max="5"
size="medium"
/>
</template>
首先,我們將需求中提到的功能整理成 props 的介面,我們會需要下列屬性:
| 屬性 | 型別 | 預設值 | 說明 |
|---|---|---|---|
modelValue |
number |
評分的值 | |
max |
number, string |
5 |
Rating 的最大值 |
name |
string |
評分欄位的名稱 | |
size |
'small', 'medium', 'large' |
'medium' |
Rating 元件的大小 |
disabled |
boolean |
false |
是否禁用評分 |
readonly |
boolean |
false |
是否為唯讀模式 |
type Numberish = number | `${number}`;
interface AtomicRatingProps {
modelValue: number;
max?: Numberish;
name?: string;
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
readonly?: boolean;
}
interface AtomicRatingEmits {
(event: 'update:modelValue', value: number): void;
}
const props = withDefaults(defineProps<AtomicRatingProps>(), {
max: 5,
size: 'medium',
name: undefined,
});
const emit = defineEmits<AtomicRatingEmits>();
實作時我們選用的 HTML 標籤會直接影響我們是否需要自行實作鍵盤操作的功能,像是 Element Plus 與 PrimeVue 都有支援鍵盤操作功能,但使用的方法卻截然不同。
Element Plus 渲染出來的 HTML

PrimeVue 渲染出來的 HTML

Element Plus 在實作上單純使用了 <span> 與 <i> 來實作,因此在底層需要自己實踐鍵盤操作的功能。而 PrimeVue 則應用了 <input type="radio"> 來實作,因此不需要額外實作就可以獲得瀏覽器原生支援的鍵盤操作功能。
我們可以看一下這個範例:
<div>
<input type="radio" name="rating" value="1" />
<input type="radio" name="rating" value="2" />
<input type="radio" name="rating" value="3" />
<input type="radio" name="rating" value="4" />
<input type="radio" name="rating" value="5" />
</div>
當 <input type="radio"> 有相同的 name 時,我們可以使用鍵盤的上、下、左、右鍵來選取不同的評分。

因此我們使用 <input type="radio"> 來實作 <AtomicRating> 元件,我們就不需要自己處理鍵盤操作的程式碼了。
const id = Math.random().toString(36).slice(2);
const modelValueLocal = ref(props.modelValue ?? 0);
const modelValueWritable = computed({
get() {
return props.modelValue ?? modelValueLocal.value;
},
set(value) {
emit('update:modelValue', value);
modelValueLocal.value = value;
},
});
<template>
<span class="atomic-rating">
<template
v-for="value in Number(max)"
:key="value"
>
<label
class="atomic-rating__item"
:for="`${id}:${value}`"
>
<StarFillSvg class="atomic-rating__image" /> <!-- Rating(Selected) -->
<StarEmptySvg class="atomic-rating__image" /> <!-- Rating(Unselected) -->
<span class="atomic-rating__reader">
{{ value }} Stars
</span>
</label>
<input
:id="`${id}:${value}`"
v-model="modelValueWritable"
class="atomic-rating__input"
:disabled="disabled"
:name="name ?? id"
type="radio"
:value="value"
>
</template>
</span>
</template>
除了 <input> 外,Reader 區塊主要是為了讓螢幕閱讀器能夠正確讀取評分的數值而存在,我們會使用 sr-only 隱藏它。
@mixin sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.atomic-rating {
&__reader,
&__input {
@include sr-only;
}
}
接著我們比對 modelValueWritable 與 value 來決定是否要顯示 <StarFillSvg> 或 <StarEmptySvg>。
const selected = (value: number) => value <= modelValueWritable.value;
<label
class="atomic-rating__item"
:for="`${id}:${value}`"
>
<StarFillSvg
v-if="selected(value)"
class="atomic-rating__image"
/>
<StarEmptySvg
v-else
class="atomic-rating__image"
/>
</label>
到這裡,我們已經完成了基本的 <AtomicRating> 元件,並且同時支援了點擊與鍵盤操作的功能。

目前的元件只能在 1 到 5 之間切換,如果我們希望支援 0 分評分,目前還尚未實現。為了實現這個功能,我們可以在開頭加上一個隱藏的 <input type="radio">,這樣如果是透過鍵盤操作的使用者就可以選到 0 分的狀態。
<template>
<span class="atomic-rating">
<label
class="atomic-rating__item atomic-rating__item--hidden"
:for="`${id}:0`"
>
Empty
</label>
<input
:id="`${id}:0`"
v-model="modelValueWritable"
class="atomic-rating__input"
:disabled="disabled"
:name="name ?? id"
type="radio"
:value="0"
>
<template
v-for="value in Number(max)"
:key="value"
>
<!-- 略 -->
</template>
</span>
</template>
.atomic-rating {
&__item--hidden {
@include sr-only;
}
}

不過光是鍵盤操作可能並不直覺,直覺上我們可能會預期可以透過點擊相同的分數來取消(歸零)評分。不過點擊到已取的 Radio 並不會取消選取,因此我們需要額外加上 click 事件來實做這個功能。
const onClick = (value) => {
if (props.disabled || props.readonly) return;
if (value !== modelValueWritable.value) return;
modelValueWritable.value = 0;
}
<template
v-for="value in Number(max)"
:key="value"
>
<label
class="atomic-rating__item"
:for="`${id}:${value}`"
@click="onClick(value)"
>
<!-- 略 -->
</label>
<input
:id="`${id}:${value}`"
v-model="modelValueWritable"
class="atomic-rating__input"
:disabled="disabled"
:name="name ?? id"
type="radio"
:value="value"
>
</template>
直接說結論,點擊事件確實觸發了,但最後的結果並不如我們預期的會將 modelValueWritable 設定成 0。

事實上,modelValueWritable 的確曾經被設定為 0,但隨著事件傳播的機制,最後 modelValueWritable 又被設定回原本的值,事件傳遞發生的經過如下。
在我們點擊了已選中的 <input> 對應的 <label> (假設 value 為 2)後:
<label> 觸發 click 事件,並將 modelValueWritable 設定為 0。<label> 往上冒泡到最外層直到 Window。modelValueWritable 改變,更新 input 的 checked 狀態。<input> 被動觸發 click 事件,將 modelValueWritable 設定為 2。<input> 往上冒泡到最外層直到 Window。<input> 被動觸發 change 事件由上述的事件觸發經過,我們證實了 modelValueWritable 確實在過程中一度被設定為 0。這是因為點擊了 <label> 後也會觸發 <input> 上的點擊事件,而 <input type="radio"> 被點擊的預設行為就是將 checked 設定為 true,因此 modelValueWritable 最後又被設定回原本的值。
要解決這個問題,我們可以 將 click 事件綁定到 <input> 上
我們可能會很直覺地將 click 事件綁定到 <label> 上,因為在視覺上 <input> 是被隱藏的。但從事件觸發經過的紀錄,我們還是可以觀察到 <input> 的 click 事件還是會被觸發,因此我們可以將 click 事件綁定到 <input> 上。
<!-- 將 `onClick` 綁定到 <input> 上 -->
<input
:id="`${id}:${value}`"
v-model="modelValueWritable"
class="atomic-rating__input"
:name="name ?? id"
type="radio"
:value="value"
@click="onClick"
>
這樣一來,當使用者點擊已選中的 <input> 時,modelValueWritable 就會被設定為 0。

<AtomicRating> 元件除了可作為表單控制元件外,我們也可以使用 readonly 來作為一般顯示時使用。在 readonly 模式下,我們可以省略 <input> 以減少 HTML 元素數量。
1 到 5 的 Rating
<template
v-for="value in Number(max)"
:key="value"
>
<component
:is="!readonly ? 'label' : 'span'"
class="atomic-rating__item"
:for="!readonly ? `${id}:${value}` : undefined"
@mouseenter="onMouseenter(value)"
>
<!-- 略 -->
<span
v-if="!readonly"
class="atomic-rating__reader"
>
{{ value }} Stars
</span>
</component>
<input
v-if="!readonly"
:id="`${id}:${value}`"
v-model="modelValueWritable"
class="atomic-rating__input"
:disabled="disabled"
:name="name ?? id"
type="radio"
:value="value"
@click="onClick"
>
</template>
代表 0 的 Radio
<template v-if="!(readonly || disabled)">
<span class="atomic-rating__group atomic-rating__group--hidden">
<label
class="atomic-rating__item"
:for="`${id}:0`"
>
Empty
</label>
<input
:id="`${id}:0`"
v-model="modelValueWritable"
class="atomic-rating__input"
:name="name ?? id"
type="radio"
:value="0"
>
</span>
</template>
這樣在 readonly 模式下,我們就可只渲染出必要的 HTML 元素,減少瀏覽器的開銷。

在某些情境中,我們可能會希望 Rating 元件能夠支援半星(0.5)的評分。

我們加入 allowHalf 這個 prop 來控制是否允許半星評分。
| 屬性 | 型別 | 預設值 | 說明 |
|---|---|---|---|
allowHalf |
boolean |
是否允許半選評分 |
interface AtomicRatingProps {
// 略
allowHalf?: boolean;
}
const props = withDefaults(defineProps<AtomicRatingProps>(), {
// 略
allowHalf: false,
});
要實作這個功能,我們可以在每個 Rating Item 上再疊上半個寬度的 Rating Item 就可以了。

我們先用一個 Group 容器將 Rating Item 包起來,並且設定 position: relative。再來只要將半個寬度的 Rating Item 包在 Group 容器中,並且放在 Rating Item 前就可以了。
<template
v-for="value in Number(max)"
:key="value"
>
<span class="atomic-rating__group">
<!-- value - 0.5 -->
<label
class="atomic-rating__item atomic-rating__item--half"
:for="`${id}:${value - 0.5}`"
>
<!-- 略 -->
</label>
<input
:id="`${id}:${value - 0.5}`"
v-model="modelValueWritable"
class="atomic-rating__input"
type="radio"
:value="value - 0.5"
>
<!-- value -->
<label
class="atomic-rating__item"
:for="`${id}:${value}`"
>
<!-- 略 -->
</label>
<input
:id="`${id}:${value}`"
v-model="modelValueWritable"
class="atomic-rating__input"
type="radio"
:value="value"
>
</span>
</template>
.atomic-rating {
&__group {
position: relative;
}
&__item {
&--half {
position: absolute;
top: 0;
left: 0;
overflow: hidden;
width: 50%;
}
}
}

接著同樣的,在唯讀模式下我們可以省略掉一些不必要的半個寬度 Rating Item。以 moduleValue 為 2.5 為例,半個寬度 Rating Item 我們只需要留 2.5 那一個,其他都可以被省略。
<template
v-for="value in Number(max)"
:key="value"
>
<span class="atomic-rating__group">
<!-- value - 0.5 -->
<!-- `readonly` 時僅保留與 modelValueWritable 相等的半個寬度的 Rating Item -->
<template
v-if="readonly
? value - 0.5 === modelValueWritable
: allowHalf
"
>
<label
class="atomic-rating__item atomic-rating__item--half"
:for="`${id}:${value - 0.5}`"
>
<!-- 略 -->
</label>
<input
:id="`${id}:${value - 0.5}`"
v-model="modelValueWritable"
class="atomic-rating__input"
type="radio"
:value="value - 0.5"
>
</template>
<!-- 略 -->
</span>
</template>
省略後只會留下必要的結構。

這樣我們就完成了半選評分的功能。
<AtomicRating> 的無障礙設計我們分成兩個部分,一個是「編輯模式」的設定與「唯讀模式」的設定。
因為在編輯模式的時候我們選用了 <input type="radio"> 實作,我們不僅不需要特別處理鍵盤操作的功能,對於螢幕閱讀器來說也已經非常友善。
唯讀模式下,我們把所有的 <input> 都移除,並且將 <label> 換成了 <span>,對於螢幕閱讀器來說,無法辨識這裡的結構代表什麼,因此我們需要使用 role 屬性與 aria-* 讓螢幕閱讀器可以辨識。
在唯讀模式下,我們使用 role="img" 來告訴螢幕閱讀器這個元素是一個圖片。
<template>
<span
class="atomic-rating"
:role="readonly ? 'img' : undefined"
>
<!-- 略 -->
</span>
</template>
除了使用 role 屬性外,我們也需要使用 aria-label 屬性來提供更多的資訊。
<template>
<span
:aria-label="readonly ? `${modelValueWritable} Stars` : undefined"
class="atomic-rating"
:role="readonly ? 'img' : undefined"
>
<!-- 略 -->
</span>
</template>
<AtomicRating> 元件為使用者提供了一個靈活且可自訂的評分系統,無論是編輯模式下作為表單控制元件,還是靜態顯示評分,都能滿足不同需求。
在編輯模式的無障礙設計中,我們使用了 <input type="radio"> 元素,藉由瀏覽器的原生功能即可達成完整的無障礙支持;在唯讀模式下,則將整個元件視為一張圖片,並使用 role="img" 和 aria-label,確保螢幕閱讀器能夠正確告知使用者這段 HTML 的含義。
原本以為 <AtomicRating> 元件的實作會非常複雜,但善加利用 HTML 原生標籤的特性後,我們也可以用簡單的方式達成需要的功能。
<AtomicRating> 原始碼:AtomicRating.vue