開關(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