Checkbox 是一個常見的網頁元件,單個使用時可以表示在兩種狀態之間切換,多個一起使用時則允許使用者在這些選項中選擇一個或多個。它適合用於問卷調查、偏好設定和法律確認等場景。
在開始實作前,我們先研究各個 UI Library 的 Checkbox 元件是如何設計的。
Element Plus
<template>
<ElCheckbox v-model="checked1" label="Option 1" />
<ElCheckbox v-model="checked2" label="Option 2" />
</template>
Element Plus 的 <ElCheckbox>
與大多數的表單控件一樣,使用 v-model
來雙向綁定資料,並且接受 label
、disabled
等屬性。
多個一起使用時,<ElCheckbox>
可以使用 value
為每個選項設定選中時的值;單個使用時則可透過 true-value
和 false-value
來設定選中與未選中時的值。
另外,有一個經較常被忽略的屬性 indeterminate
,這是 <input type="checkbox">
的原生屬性,它可以讓 Checkbox 進入不確定的狀態,通常用於表示部分選中的情況。
Vuetify
<template>
<VCheckbox
v-model="checked"
label="Checkbox"
/>
</template>
Vuetify 的 <VCheckbox>
使用 v-model
來雙向綁定資料,多選時支援 value
,單選時支援 true-value
和 false-value
。此外,<VCheckbox>
也支援 indeterminate
設定。
除了常見設定外,Vuetify 還支援 color
設定,可以自行定義 Checkbox 的顏色,也可以設定 message
與 error
,這在設計系統時非常有用。
綜合以上並結合自身經驗,我們統整出 <AtomicCheckbox>
的功能:
v-model
來綁定選擇的值。label
來設定 Checkbox 的文字。labelPlacement
來設定 Checkbox 文字的位置。value
來設定多個一起使用時,選中時的值。trueValue
和 falseValue
來設定選中與未選中時的值。indeterminate
來設定 Checkbox 的不確定狀態。disabled
來設定 Checkbox 的禁用狀態。color
來設定 Checkbox 的顏色。使用結構如下:
單選
<template>
<AtomicCheckbox
v-model="checked"
label="Checkbox"
label-placement="right"
true-value="1"
false-value="0"
color="primary"
/>
</template>
多選
我們可以直接使用 v-for
來生成多個 Checkbox,並使用 value
來設定選中時的值。
<template>
<AtomicCheckbox
v-for="city in cities"
:key="city.value"
v-model="checked"
color="primary"
:label="city.name"
:value="city.value"
/>
</template>
首先,我們將需求中提到的功能整理成 props
的介面,我們會需要下列屬性:
屬性 | 型別 | 預設值 | 說明 |
---|---|---|---|
modelValue |
any |
選中時的值 | |
label |
string |
Checkbox 的 label 文字 | |
labelPlacement |
left , right , top , bottom |
right |
Checkbox 的 label 文字的位置 |
value |
any |
true |
選中時的值 |
trueValue |
any |
true |
選中時的值 |
falseValue |
any |
false |
未選中時的值 |
indeterminate |
boolean |
false |
是否為不確定狀態 |
disabled |
boolean |
false |
是否禁用 Checkbox |
color |
primary , success , warning , danger , info |
primary |
Checkbox 的顏色 |
interface AtomicCheckboxProps {
modelValue?: any;
value?: any;
name?: string;
indeterminate?: boolean;
label?: string;
labelPlacement?: 'top' | 'left' | 'right' | 'bottom';
hideLabel?: boolean;
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
trueValue?: any;
falseValue?: any;
message?: string;
disabled?: boolean;
error?: boolean;
}
interface AtomicCheckboxEmits {
(event: 'update:modelValue', value: any): void;
}
const props = withDefaults(defineProps<AtomicCheckboxProps>(), {
modelValue: undefined,
value: undefined,
name: undefined,
label: undefined,
labelPlacement: 'right',
color: 'primary',
trueValue: undefined,
falseValue: undefined,
message: undefined,
});
const emit = defineEmits<AtomicCheckboxEmits>();
接下來的 <AtomicCheckbox>
、<AtomicSwitch>
與 <AtomicRadio>
,使用的包裝器為 <AtomicFormLabelField>
,作用與設計與 <AtomicFormField>
類似,所以我們這邊只簡單帶過。
<AtomicFormLabelField>
內部的模板架構如下:
<template>
<div class="atomic-form-label-field">
<label class="atomic-form-label-field__container">
<span class="atomic-form-label-field__label">
<slot name="label">
{{ label }}
</slot>
</span>
<span class="atomic-form-label-field__control">
<slot name="default">
<!-- 這裡放入 input -->
</slot>
</span>
</label>
<div class="atomic-form-label-field__message">
<slot name="message">
{{ message }}
</slot>
</div>
</div>
</template>
需要注意的是,我們利用了 <input>
包在 <label>
內的特性,這樣可以讓使用者點擊文字時也可以選取到 Checkbox。
不過有個小問題我們在 <AtomicSelect>
中提過,當我們點擊文字時,會觸發兩次點擊事件。
<div @click="() => console.log('click')">
<label>
<span>Checkbox</span>
<input type="checkbox">
</label>
</div>
實際執行時會印出兩次 click
,這是因為當我們點擊文字時,會先觸發 <label>
的 click
事件,接著觸發 <input>
的 click
事件。兩個元素的點擊事件往上冒泡後,我們在外層收集到的事件就會是兩次。
所以不論採用 <label>
包裹 <input>
的方式,還是使用 for
屬性來綁定,都會有這個問題。建議的解決方式是在 <input>
上加上 @click.stop
來阻止事件冒泡。
<div @click="() => console.log('click')">
<label>
<span>Checkbox</span>
<input type="checkbox" @click.stop>
</label>
</div>
<div @click="() => console.log('click')">
<label>Label</label>
<input type="text" @click.stop>
</div>
簡介完 <AtomicFormLabelField>
我們進入主題,<AtomicCheckbox>
。
<AtomicCheckbox>
元件的模板非常簡單,結構如下:
<template>
<AtomicFormLabelField
class="atomic-checkbox"
:class="{
'atomic-checkbox--disabled': disabled,
[`atomic-checkbox--${color}`]: !!color,
}"
>
<template #label>
<slot name="label" />
</template>
<input
v-model="modelValueWritable"
class="atomic-checkbox__input"
type="checkbox"
>
<template #message>
<slot name="message" />
</template>
</AtomicFormLabelField>
</template>
<AtomicCheckbox>
不像 <AtomicTextField>
有較多的 v-model
修飾符需要支援,我們只需將 modelValue
轉換成 modelValueWritable
後綁定即可。
const modelValueLocal = ref(props.modelValue ?? false);
const modelValueWritable = computed({
get() {
return props.modelValue ?? modelValueLocal.value;
},
set(value) {
emit('update:modelValue', value);
modelValueLocal.value = value;
},
});
接著,我們將 trueValue
與 falseValue
以外,關於 <input type="checkbox">
的屬性設定全部綁定上去。
<input
v-model="modelValueWritable"
class="atomic-checkbox__input"
:disabled="disabled"
:indeterminate="indeterminate"
:name="name"
type="checkbox"
:value="value"
>
這樣,現在看起來已經可以順利運作了。
單個使用:
多個一起使用:
加上 trueValue
與 falseValue
試試看效果:
<input
v-model="modelValueWritable"
class="atomic-checkbox__input"
...
:true-value="trueValue"
:false-value="falseValue"
>
trueValue
與 falseValue
加上 trueValue
與 falseValue
後看起來一切正常。
<template>
<AtomicCheckbox
label="我同意幫 Alex Liu 分享他的鐵人賽文章給所有的親朋好友"
message="必填"
true-value="分享拉!哪次不分享"
false-value="我只想要自己看"
/>
</template>
但沒使用 trueValue
與 falseValue
的版本卻壞掉了。
要解決這個問題有兩個方法:
trueValue
與 falseValue
的預設值應該是 true
與 false
。trueValue
與 falseValue
沒有設定時,不綁定到 <input>
上。第一種方法相對簡單,我們只需要在 props
中設定預設值即可。
const props = withDefaults(defineProps<AtomicCheckboxProps>(), {
// ...
trueValue: true,
falseValue: false,
});
不過這種做法在開發人員沒有使用 trueValue
與 falseValue
時,渲染出來的 DOM 會長這樣:
這完全不影響使用結果,但我自己會希望在開發人員沒有使用 trueValue
與 falseValue
時,不要渲染這些屬性。因此,我可能會選擇第二種方法。
const inputAttrs = computed(() => {
const { trueValue, falseValue } = props;
return {
...(!isNullOrUndefined(trueValue) ? { trueValue } : {}),
...(!isNullOrUndefined(falseValue) ? { falseValue } : {}),
};
});
<input
v-model="modelValueWritable"
class="atomic-checkbox__input"
...
v-bind="inputAttrs"
>
這樣,當 trueValue
與 falseValue
沒有被設定時,就不會綁定到 <input>
上。當然,如果第一種結果已經可以接受的話,這是個相對簡便的作法。
接著我們加上 indeterminate
的支援。
在 Vue 裡面,如果 Checkbox 的 indeterminate
為 true
,不論 Checkbox 是否被選取,都只會顯示不確定狀態的 UI,並且就算我們透過 JavaScript 改變選取狀態,UI 也不會有任何變動。直到我們點擊 Checkbox,DOM 上的 indeterminate
會被設定為 false
,並觸發選取事件的變更。
為了達成這個效果,我們需要做幾件事:
isIndeterminate
的狀態,開發人員設定的為初始值。indeterminate
的變化,當資料變化時,更新內部的 isIndeterminate
。<input>
的 change
事件,當收到事件時,強制將 isIndeterminate
設定為 false
。const isIndeterminate = ref(!!props.indeterminate);
watch(
() => props.indeterminate,
value => {
isIndeterminate.value = !!value;
},
{ immediate: true }
);
const onInputChange = () => {
isIndeterminate.value = false;
};
<input
v-model="modelValueWritable"
class="atomic-checkbox__input"
type="checkbox"
...
@change="onInputChange"
>
這樣一來,我們就可以達到 indeterminate
的效果了。
接著討論一下 UI 如何處理。若要自定義選取的 Icon,我們可以使用 CSS 來完成。
首先在結構部分,我們可以把需要的 Icon 放在 <input>
後面,這樣我們就可以透過 CSS 隱藏原生 <input>
並控制 Icon 的樣式。
<input
v-model="modelValueWritable"
class="atomic-checkbox__input"
type="checkbox"
>
<i class="atomic-checkbox__icon atomic-checkbox__icon--indeterminate" />
<i class="atomic-checkbox__icon atomic-checkbox__icon--checked" />
<i class="atomic-checkbox__icon atomic-checkbox__icon--unchecked" />
.atomic-checkbox {
&__input {
@include sr-only;
}
}
我們可以應用 <input>
的偽類(pseudo-class)搭配通用同層選擇器(Subsequent-sibling combinator)來選取 Icon。
.atomic-checkbox {
&__icon--indeterminate {
display: none;
}
&__input:checked ~ &__icon--unchecked,
&__input:not(:checked) ~ &__icon--checked {
display: none;
}
&__input:indeterminate ~ &__icon--indeterminate {
display: unset;
}
&__input:indeterminate ~ &__icon--checked,
&__input:indeterminate ~ &__icon--unchecked {
display: none;
}
}
邏輯大致如下:
atomic-checkbox__icon--indeterminate
預設隱藏。<input>
被選取時,atomic-checkbox__icon--unchecked
會被隱藏。<input>
未被選取時,atomic-checkbox__icon--checked
會被隱藏。<input>
的 indeterminate
為 true
時,atomic-checkbox__icon--indeterminate
會被顯示,其他則隱藏。這樣的好處是我們不用使用 JavaScript 來控制 Icon 的顯示與隱藏,只需在 CSS 中控制即可,符合「能用 CSS 就不要用 JavaScript」的理念。
另一種方式是使用 JavaScript 來控制顯示,這在需要使用較複雜的 SVG 作為 Icon 時特別有用,使用這種方式可以讓我們生成的 HTML 結構更加乾淨。
<input
v-model="modelValueWritable"
class="atomic-checkbox__input"
type="checkbox"
>
<svg
v-if="isIndeterminate"
class="atomic-checkbox__icon atomic-checkbox__icon--indeterminate"
>
<!-- path -->
</svg>
<svg
v-else-if="isChecked"
class="atomic-checkbox__icon atomic-checkbox__icon--checked"
>
<!-- path -->
</svg>
<svg
v-else
class="atomic-checkbox__icon atomic-checkbox__icon--unchecked"
>
<!-- path -->
</svg>
結構順序不變,但我們在上面加上了 v-if
、v-else-if
與 v-else
來控制 Icon 的顯示。
現在需要從 <input>
上面同步 isChecked
的值,我們可以使用 onUpdated
,這個 Lifecycle Hook 進行同步。
const inputRef = ref<HTMLInputElement>();
const isChecked = ref(false);
const handleSyncChecked = () => {
const input = inputRef.value;
isChecked.value = input?.checked ?? false;
isIndeterminate.value = input?.indeterminate ?? false;
};
onMounted(handleSyncChecked);
onUpdated(handleSyncChecked);
<input
ref="inputRef"
v-model="modelValueWritable"
class="atomic-checkbox__input"
type="checkbox"
@click.stop
>
前面我們在 <input>
上監聽了 change
事件,在這裡我們可以選擇跟同步 isChecked
的流程做在一起,可以少寫一個監聽事件。
這樣就完成了 <AtomicCheckbox>
的實作。
<AtomicCheckbox>
是一個簡單的元件,它不需要處理 v-model
的修飾符,也不需處理太多的邏輯,我們只需要處理 trueValue
、falseValue
與 indeterminate
的狀態即可。
在樣式部分,我們可以使用 CSS 來控制 Icon 的顯示,也可以使用 JavaScript 來控制 Icon 的顯示。使用 CSS 的好處是我們可以讓瀏覽器自行判斷要顯示的 Icon,適合用在像是使用 icon font 的設計;在使用 SVG 作為 Icon 的情境下,使用 v-if
、v-else
來控制顯示可以讓我們的 HTML 結構更加精簡、乾淨。這兩種方式各有優缺點,可以根據自己的需求來選擇。
<AtomicCheckbox>
原始碼:AtomicCheckbox.vue