iT邦幫忙

2024 iThome 鐵人賽

DAY 28
1
Modern Web

為你自己寫 Vue Component系列 第 28

[為你自己寫 Vue Component] AtomicRating

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicRating

Rating 元件讓使用者可以對某項目進行評分,通常以星星或其他符號來表示評分等級。Rating 元件的核心功能是提供一種直觀的方式,讓使用者針對產品、服務或內容表達他們的滿意度或偏好。它可以是靜態顯示使用者的評分,也可以是可互動的,讓使用者自行選擇評分。

元件分析

元件架構

AtomicRating 元件架構

  1. Rating(Selected / Fill):當目前的值大於等於該 Rating 時的 UI。
  2. Rating(Unselected / Empty):當目前的值小於該 Rating 時的 UI。

功能設計

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

Element Plus

Element Plus Rating

<template>
  <ElRate
    v-model="value1"
    :max="5"
    size="large"
  />
</template>

Element Plus 的 <ElRate> 元件可以使用 v-model 來雙向綁定評分值,並且可以設定 max 來設定最大值,size 來設定元件大小。

除此之外,Element Plus 也實現了鍵盤操作功能,使用者可以使用鍵盤上的左右鍵來增減評分值。

Element Plus Rating Keyboard

Vuetify

Vuetify Rating

<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

PrimeVue Rating

<template>
  <Rating v-model="value" :stars="5" />
</template>

PrimeVue 的 <Rating> 元件也可以使用 v-model 來雙向綁定評分值,並且可以設定 stars 來設定最大值。

PrimeVue 在鍵盤操作上也有很好的支援,使用者可以使用鍵盤上的左右鍵來增減評分的值。

PrimeVue Rating Keyboard

綜合以上並結合自身經驗,我們統整出 <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

AtomicRating Element Plus HTML

PrimeVue 渲染出來的 HTML

AtomicRating 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 時,我們可以使用鍵盤的上、下、左、右鍵來選取不同的評分。

AtomicRating Radio Keyboard

因此我們使用 <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;
  }
}

接著我們比對 modelValueWritablevalue 來決定是否要顯示 <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> 元件,並且同時支援了點擊與鍵盤操作的功能。

最基本的 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;
  }
}

AtomicRating with 0

不過光是鍵盤操作可能並不直覺,直覺上我們可能會預期可以透過點擊相同的分數來取消(歸零)評分。不過點擊到已取的 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

AtomicRating 點擊已選取的 Radio 設定為 0 失敗

事實上,modelValueWritable 的確曾經被設定為 0,但隨著事件傳播的機制,最後 modelValueWritable 又被設定回原本的值,事件傳遞發生的經過如下。

在我們點擊了已選中的 <input> 對應的 <label> (假設 value 為 2)後:

  1. <label> 觸發 click 事件,並將 modelValueWritable 設定為 0
  2. click 事件從 <label> 往上冒泡到最外層直到 Window。
  3. modelValueWritable 改變,更新 inputchecked 狀態。
  4. <input> 被動觸發 click 事件,將 modelValueWritable 設定為 2
  5. click 事件從 <input> 往上冒泡到最外層直到 Window。
  6. <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 點擊已選取的 Radio 設定為 0

Readonly

<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 元素,減少瀏覽器的開銷。

AtomicRating Readonly HTML

進階功能

允許半選

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

AtomicRating with Half Star

我們加入 allowHalf 這個 prop 來控制是否允許半星評分。

屬性 型別 預設值 說明
allowHalf boolean 是否允許半選評分
interface AtomicRatingProps {
  // 略
  allowHalf?: boolean;
}

const props = withDefaults(defineProps<AtomicRatingProps>(), {
  // 略
  allowHalf: false,
});

要實作這個功能,我們可以在每個 Rating Item 上再疊上半個寬度的 Rating Item 就可以了。

AtomicRating 半個寬度 Rating 蓋在 Rating 上

我們先用一個 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%;
    }
  }
}

AtomicRating with Half Star

接著同樣的,在唯讀模式下我們可以省略掉一些不必要的半個寬度 Rating Item。以 moduleValue2.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 with Half Star Readonly HTML

這樣我們就完成了半選評分的功能。

無障礙

<AtomicRating> 的無障礙設計我們分成兩個部分,一個是「編輯模式」的設定與「唯讀模式」的設定。

因為在編輯模式的時候我們選用了 <input type="radio"> 實作,我們不僅不需要特別處理鍵盤操作的功能,對於螢幕閱讀器來說也已經非常友善。

唯讀模式下,我們把所有的 <input> 都移除,並且將 <label> 換成了 <span>,對於螢幕閱讀器來說,無法辨識這裡的結構代表什麼,因此我們需要使用 role 屬性與 aria-* 讓螢幕閱讀器可以辨識。

角色 Role

在唯讀模式下,我們使用 role="img" 來告訴螢幕閱讀器這個元素是一個圖片。

<template>
  <span
    class="atomic-rating"
    :role="readonly ? 'img' : undefined"
  >
    <!-- 略 -->
  </span>
</template>

ARIA 屬性

除了使用 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 原生標籤的特性後,我們也可以用簡單的方式達成需要的功能。

參考資料


上一篇
[為你自己寫 Vue Component] AtomicRadio
下一篇
[為你自己寫 Vue Component] 設計 Server Side Rendering(Universal Rendering)友善的元件
系列文
為你自己寫 Vue Component30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言