iT邦幫忙

2024 iThome 鐵人賽

DAY 26
2
Modern Web

為你自己寫 Vue Component系列 第 26

[為你自己寫 Vue Component] AtomicSwitch

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicSwitch

開關(Switch)是用來表示 on 或 off 狀態的元件。它與 Checkbox 相似,但不同於 Checkbox 允許實現第三種中間狀態的選項,Switch 更強調在兩種狀態之間切換。

此外,Switch 通常用於即時生效的操作,例如網頁 Dark / Light 模式的切換、勿擾模式的切換。

元件分析

元件架構

AtomicSwitch 元件架構

  1. Track:Switch 的軌道,用來表示當前的狀態。
  2. Thumb:Switch 的滑塊,用來表示當前的狀態。
  3. Label:Switch 的標籤,包裹著 Track 與文字的部分。

功能設計

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

Element Plus

Element Plus Switch

<template>
  <ElSwitch
    v-model="value"
    :active-value="true"
    :inactive-value="false"
    active-text="Open"
    inactive-text="Close"
  />
</template>

Element Plus 的 <ElSwitch> 有幾個重要的屬性,例如 activeValueinactiveValue 用來設定開關的值,這與 Checkbox 的 trueValuefalseValue 相似,只是命名不同;activeTextinactiveText 用來設定開關的文字。

Vuetify

Vuetify Switch

<template>
  <VSwitch
    label="Switch"
    messages="messages"
    :true-value="true"
    :false-value="false"
  />
</template>

Vuetify 的 <VSwitch> 同樣提供了 trueValuefalseValue 屬性來設定開關的值,並且提供了 messages 作為提示訊息顯示。

特別的是,Vuetify 的 <VSwitch> 也提供了 indeterminate 的設定,雖然與我們前面提到的兩種狀態之間切換的定義衝突,但這是為了極端情況的考量。

綜合以上並結合自身經驗,我們統整出 <AtomicSwitch> 的功能:

  • 使用 v-model 綁定開關的值。
  • 可以透過 label 設定 Switch 的文字。
  • 可以透過 labelPlacement 設定 Switch 文字的位置。
  • 可以透過 activeValueinactiveValue 設定開關的值。
  • 可以透過 activeTextinactiveText 設定開關的文字。
  • 可以透過 disabled 設定 Switch 的禁用狀態。
  • 可以透過 color 設定 Switch 的顏色。

使用結構如下:

<template>
  <AtomicSwitch
    v-model="value"
    :active-value="true"
    :inactive-value="false"
    active-text="on"
    inactive-text="off"
    color="primary"
  />
</template>

元件實作

首先,我們將需求中提到的功能整理成 props 的介面,會需要以下屬性:

屬性 型別 預設值 說明
modelValue any 選中時的值
label string Switch 的 label 文字
labelPlacement left, right, top, bottom right Switch 的 label 文字的位置
activeValue any true 開啟時的值
inactiveValue any false 未開啟時的值
activeText string 開啟時的描述文字
inactiveText string 未開啟時的描述文字
disabled boolean false 是否禁用 Switch
color primary, success, warning, danger, info primary Switch 的顏色
interface AtomicSwitchProps {
  modelValue?: T;
  name?: string;
  indeterminate?: boolean;
  label?: string;
  labelPlacement?: 'top' | 'left' | 'right' | 'bottom';
  hideLabel?: boolean;
  color?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
  activeValue?: T;
  inactiveValue?: T;
  activeText?: string;
  inactiveText?: string;
  disabled?: boolean;
  message?: string;
  error?: boolean;
}

interface AtomicSwitchEmits {
  (event: 'update:modelValue', value: T | undefined): void;
}

const props = withDefaults(defineProps<AtomicSwitchProps>(), {
  modelValue: undefined,
  name: undefined,
  label: undefined,
  labelPlacement: 'left',
  color: 'primary',
  activeValue: undefined,
  inactiveValue: undefined,
  activeText: undefined,
  inactiveText: undefined,
  message: undefined,
});

const emit = defineEmits<AtomicSwitchEmits>();

先處理基本 UI,我們以 <AtomicFormLabelField> 作為包裝元件,剩下的模板非常簡單,結構如下:

<template>
  <AtomicFormLabelField
    class="atomic-switch"
    :class="{
      'atomic-switch--disabled': disabled,
      [`atomic-switch--${color}`]: !!color,
    }"
    v-bind="filedProps"
  >
    <input
      v-model="modelValueWritable"
      v-bind="inputAttrs"
      class="atomic-switch__input"
      :disabled="disabled"
      :name="name"
      type="checkbox"
      @click.stop
    >
    <span class="atomic-switch__track">
      <span class="atomic-switch__thumb" />
    </span>
  </AtomicFormLabelField>
</template>

inputAttrs 的用意與在 <AtomicCheckbox> 中一樣,在這裡我們需要把 activeValueinactiveValue 轉換成 trueValuefalseValue 設定到 <input> 上:

const inputAttrs = computed(() => {
  const { activeValue, inactiveValue } = props;
  return {
    name: props.name,
    disabled: props.disabled,
    ...(!isNullOrUndefined(activeValue) ? { 'true-value': activeValue } : {}),
    ...(!isNullOrUndefined(inactiveValue)
      ? { 'false-value': inactiveValue }
      : {}),
  };
});

再來,我們需要隱藏 <input> 並使用 Track 和 Thumb 來呈現 Switch 的樣式。

.atomic-switch {
  &__input {
    @include sr-only;
  }

  &__track,
  &__thumb {
    transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
    transition-duration: 300ms;
  }

  &__track {
    position: relative;
    display: inline-block;
    width: 54px;
    height: 32px;
    background-color: #dedede;
    border-radius: 9999px;
    transition-property: background-color;
  }

  &__thumb {
    position: absolute;
    top: 3px;
    left: 3px;
    width: 26px;
    height: 26px;
    background-color: white;
    border-radius: 9999px;
    box-shadow: 1px 1px 0 #00000026;
    transition-property: transform;
  }
}

Atomic Switch 基本外觀

接下來,我們需要在 <input type="checkbox"> 被勾取時讓 Thumb 移動到右邊。這裡不透過添加 class 的方式實作,我們使用 CSS 的偽元素 :checked 來達成:

.atomic-switch {
  &__input:checked + &__track {
    background-color: var(--switch-color);
  }

  &__input:checked + &__track &__thumb {
    transform: translateX(22px);
  }
}

這樣我們就得到了開關效果的 Switch 元件。

Atomic Switch 效果

不過,我們現在的寬高都是固定寫死的。如果未來希望允許使用 size 選項,或是讓開發人員自定義寬高,我們可以透過 CSS 變數來達成。我們將幾個變數設定出來:

Atomic Switch CSS Variables

由圖片的標注,我們可以得到 Thumb 寬度 的計算方式。

$$\text{Thumb 寬度} = \text{Track 高度} - 2 \times \text{padding}$$

.atomic-switch {
  --switch-width: 54px;
  --switch-height: 32px;
  --switch-padding: 3px;

  &__track {
    position: relative;
    display: inline-block;
    width: var(--switch-width);
    height: var(--switch-height);
    background-color: #dedede;
    border-radius: 9999px;
    transition-property: background-color;
  }

  &__input:checked + &__track {
    background-color: var(--switch-color);
  }

  &__thumb {
    position: absolute;
    top: var(--switch-padding);
    left: var(--switch-padding);
    width: calc(var(--switch-height) - 2 * var(--switch-padding));
    height: calc(var(--switch-height) - 2 * var(--switch-padding));
    background-color: white;
    border-radius: 9999px;
    box-shadow: 1px 1px 0 #00000026;
    transition-property: transform;
  }
}

Thumb 的寬高等於 Switch 的高減去兩倍的 padding(上方的 padding 與下方的 padding)。可能會有點難理解的是 Thumb 移動的計算,我們可以從下方觀察出 Thumb 移動的距離該如何計算。

Atomic Switch Thumb 移動

由圖中我們可以看到,Thumb 最終的位置是 Track 寬度減去 Thumb 的寬度再減去 padding 的距離:

$$\text{Thumb 最終位置} = \text{Track 寬度} - (\text{Thumb 寬度} + \text{padding})$$

假設從 0 開始偏移,需要移動上面公式算出來的距離。不過在一開始也有一個 padding 的距離,所以我們需要加上 padding 的距離:

$$\text{Thumb 最終位置} = \text{Track 寬度} - (\text{Thumb 寬度} + \text{padding}) - \text{padding}$$

進一步簡化:

$$\text{Thumb 最終位置} = \text{Track 寬度} - \text{Thumb 寬度} - 2 \times \text{padding}$$

我們把上面 Thumb 寬度的計算式帶入:

$$\text{Thumb 寬度} = \text{Track 高度} - 2 \times \text{padding}$$

$$\text{Thumb 最終位置} = \text{Track 寬度} - (\text{Track 高度} - 2 \times \text{padding}) - 2 \times \text{padding}$$

最後得出:

$$\text{Thumb 最終位置} = \text{Track 寬度} - \text{Track 高度}$$

把結果帶入 translateX

.atomic-switch {
  &__input:checked + &__track &__thumb {
    transform: translateX(calc(var(--switch-width) - var(--switch-height)));
  }
}

這樣一來,我們就為未來擴充或開發人員自定義寬高預留了一些彈性。

接下來我們加入 activeTextinactiveText 的支援,這裡我們使用 v-if 來判斷是否有文字。

<template>
  <AtomicFormLabelField class="atomic-switch">
    <span
      v-if="inactiveText"
      class="atomic-switch__text atomic-switch__text--inactive"
    >
      {{ inactiveText }}
    </span>
    <!-- input -->
    <!-- switch ui -->
    <span
      v-if="activeText"
      class="atomic-switch__text atomic-switch__text--active"
    >
      {{ activeText }}
    </span>
  </AtomicFormLabelField>
</template>

接著,我們讓當前狀態對應的文字亮起來,我們可以使用 :has():not() 來達成:

.atomic-switch {
  &:has(&__input:checked) &__text--active,
  &:not(:has(&__input:checked)) &__text--inactive {
    color: var(--switch-color);
  }
}

Atomic Switch Text

這樣我們就完成了 <AtomicSwitch> 的實作。

無障礙

在無障礙方面,我們使用了 <input type="checkbox"> 作為基礎,讓使用者可以透過鍵盤操作來切換開關。也可以透過 tab 鍵來聚焦到開關,並透過 Enter 鍵來切換開關的狀態。

因為我們使用了原生的元素作為基礎,我們不需要特別告訴輔助技術現在的開關狀態為何,我們只需要告訴瀏覽器,現在的元件是一個開關即可。

角色 Role

role="switch"

switch 角色在功能上與 checkbox 角色相同,但有一個主要區別:switch 角色表示 "開(on)" 和 "關(off)" 狀態,而 checkbox 角色則表示 "已勾選(checked)" 和 "未勾選(unchecked)" 的狀態。這個區別使得 switch 角色更適合表示明確的開關功能。

<input
  v-model="modelValueWritable"
  v-bind="inputAttrs"
  class="atomic-switch__input"
  :disabled="disabled"
  :name="name"
  role="switch"
  type="checkbox"
>

ARIA 屬性

我們不需要告訴輔助技術當前開關的狀態,但因為我們支援了 activeTextinactiveText,為了要讓輔助技術可以讀到當前應該給使用者知曉的文字,我們需要將不是當前狀態對應的文字隱藏起來。

需要先得知當前狀態是什麼,我們可以透過 modelValueWritableactiveValue 來判斷:

const isChecked = computed(() => {
  if (props.activeValue === undefined) return !!modelValueWritable.value;
  return modelValueWritable.value === props.activeValue;
})

接著我們可以透過 aria-hidden 來隱藏對應的文字:

<template>
  <AtomicFormLabelField class="atomic-switch">
    <span
      v-if="inactiveText"
      :aria-hidden="`${isChecked}`"
      class="atomic-switch__text atomic-switch__text--inactive"
    >
      {{ inactiveText }}
    </span>
    <!-- input -->
    <!-- switch ui -->
    <span
      v-if="activeText"
      :aria-hidden="`${!isChecked}`"
      class="atomic-switch__text atomic-switch__text--active"
    >
      {{ activeText }}
    </span>
  </AtomicFormLabelField>
</template>

總結

<AtomicSwitch> 的實作中,我們使用了原生的 <input type="checkbox"> 作為基礎。

在 UI 部分,我們透過 CSS 實現了開關樣式的切換,推導了 Switch 的寬高與 Thumb 位移之間的關係,並為自定義 size 提供了調整的彈性。

此外,我們支援了 activeTextinactiveText 的文字描述,並透過無障礙設計,加上了對應的 Role 與 ARIA 屬性,以確保輔助技術能夠正確讀取相應的文字。

在 Switch 的 A11y Pattern 中列舉了許多應該要加上的屬性,但由於我們選用了原生的 <input> 元素作為基礎,因此節省了大量的工作。

參考資料


上一篇
[為你自己寫 Vue Component] AtomicCheckbox
下一篇
[為你自己寫 Vue Component] AtomicRadio
系列文
為你自己寫 Vue Component30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言