![[為你自己寫 Vue Component] AtomicCheckbox](https://ithelp.ithome.com.tw/upload/images/20241002/201204846GPWwQ1QFn.png)
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