![[為你自己寫 Vue Component] AtomicSwitch](https://ithelp.ithome.com.tw/upload/images/20241003/20120484rH3TNwRqfh.png)
開關(Switch)是用來表示 on 或 off 狀態的元件。它與 Checkbox 相似,但不同於 Checkbox 允許實現第三種中間狀態的選項,Switch 更強調在兩種狀態之間切換。
此外,Switch 通常用於即時生效的操作,例如網頁 Dark / Light 模式的切換、勿擾模式的切換。

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

<template>
  <ElSwitch
    v-model="value"
    :active-value="true"
    :inactive-value="false"
    active-text="Open"
    inactive-text="Close"
  />
</template>
Element Plus 的 <ElSwitch> 有幾個重要的屬性,例如 activeValue 和 inactiveValue 用來設定開關的值,這與 Checkbox 的 trueValue 和 falseValue 相似,只是命名不同;activeText 和 inactiveText 用來設定開關的文字。
Vuetify

<template>
  <VSwitch
    label="Switch"
    messages="messages"
    :true-value="true"
    :false-value="false"
  />
</template>
Vuetify 的 <VSwitch> 同樣提供了 trueValue 和 falseValue 屬性來設定開關的值,並且提供了 messages 作為提示訊息顯示。
特別的是,Vuetify 的 <VSwitch> 也提供了 indeterminate 的設定,雖然與我們前面提到的兩種狀態之間切換的定義衝突,但這是為了極端情況的考量。
綜合以上並結合自身經驗,我們統整出 <AtomicSwitch> 的功能:
v-model 綁定開關的值。label 設定 Switch 的文字。labelPlacement 設定 Switch 文字的位置。activeValue 和 inactiveValue 設定開關的值。activeText 和 inactiveText 設定開關的文字。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> 中一樣,在這裡我們需要把 activeValue 和 inactiveValue 轉換成 trueValue 與 falseValue 設定到 <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;
  }
}

接下來,我們需要在 <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 元件。

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

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

.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 移動的距離該如何計算。

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

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

進一步簡化:

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


最後得出:

把結果帶入 translateX:
.atomic-switch {
  &__input:checked + &__track &__thumb {
    transform: translateX(calc(var(--switch-width) - var(--switch-height)));
  }
}
這樣一來,我們就為未來擴充或開發人員自定義寬高預留了一些彈性。
接下來我們加入 activeText 和 inactiveText 的支援,這裡我們使用 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);
  }
}

這樣我們就完成了 <AtomicSwitch> 的實作。
在無障礙方面,我們使用了 <input type="checkbox"> 作為基礎,讓使用者可以透過鍵盤操作來切換開關。也可以透過 tab 鍵來聚焦到開關,並透過 Enter 鍵來切換開關的狀態。
因為我們使用了原生的元素作為基礎,我們不需要特別告訴輔助技術現在的開關狀態為何,我們只需要告訴瀏覽器,現在的元件是一個開關即可。
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"
>
我們不需要告訴輔助技術當前開關的狀態,但因為我們支援了 activeText 和 inactiveText,為了要讓輔助技術可以讀到當前應該給使用者知曉的文字,我們需要將不是當前狀態對應的文字隱藏起來。
需要先得知當前狀態是什麼,我們可以透過 modelValueWritable 與 activeValue 來判斷:
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 提供了調整的彈性。
此外,我們支援了 activeText 和 inactiveText 的文字描述,並透過無障礙設計,加上了對應的 Role 與 ARIA 屬性,以確保輔助技術能夠正確讀取相應的文字。
在 Switch 的 A11y Pattern 中列舉了許多應該要加上的屬性,但由於我們選用了原生的 <input> 元素作為基礎,因此節省了大量的工作。
<AtomicSwitch> 原始碼:AtomicSwitch.vue