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"
@click.stop
>
</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"
@click.stop
>
</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.stop="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.stop="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