iT邦幫忙

2024 iThome 鐵人賽

DAY 25
2
Modern Web

為你自己寫 Vue Component系列 第 25

[為你自己寫 Vue Component] AtomicCheckbox

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicCheckbox

Checkbox 是一個常見的網頁元件,單個使用時可以表示在兩種狀態之間切換,多個一起使用時則允許使用者在這些選項中選擇一個或多個。它適合用於問卷調查、偏好設定和法律確認等場景。

AtomicCheckbox

元件分析

元件架構

AtomicCheckbox 元件架構

  1. Label:Checkbox 的標籤,包裹著 Icon 與文字的部分。
  2. Icon:Checkbox 的 Icon,用來表示選中與未選中的狀態。

功能設計

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

Element Plus

Element Plus Checkbox

<template>
  <ElCheckbox v-model="checked1" label="Option 1" />
  <ElCheckbox v-model="checked2" label="Option 2" />
</template>

Element Plus 的 <ElCheckbox> 與大多數的表單控件一樣,使用 v-model 來雙向綁定資料,並且接受 labeldisabled 等屬性。

多個一起使用時,<ElCheckbox> 可以使用 value 為每個選項設定選中時的值;單個使用時則可透過 true-valuefalse-value 來設定選中與未選中時的值。

另外,有一個經較常被忽略的屬性 indeterminate,這是 <input type="checkbox"> 的原生屬性,它可以讓 Checkbox 進入不確定的狀態,通常用於表示部分選中的情況。

Vuetify

Vuetify Checkbox

<template>
  <VCheckbox
    v-model="checked" 
    label="Checkbox"
  />
</template>

Vuetify 的 <VCheckbox> 使用 v-model 來雙向綁定資料,多選時支援 value,單選時支援 true-valuefalse-value。此外,<VCheckbox> 也支援 indeterminate 設定。

除了常見設定外,Vuetify 還支援 color 設定,可以自行定義 Checkbox 的顏色,也可以設定 messageerror,這在設計系統時非常有用。

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

  • 使用 v-model 來綁定選擇的值。
  • 可以透過 label 來設定 Checkbox 的文字。
  • 可以透過 labelPlacement 來設定 Checkbox 文字的位置。
  • 可以透過 value 來設定多個一起使用時,選中時的值。
  • 可以透過 trueValuefalseValue 來設定選中與未選中時的值。
  • 可以透過 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;
  },
});

接著,我們將 trueValuefalseValue 以外,關於 <input type="checkbox"> 的屬性設定全部綁定上去。

<input
  v-model="modelValueWritable"
  class="atomic-checkbox__input"
  :disabled="disabled"
  :indeterminate="indeterminate"
  :name="name"
  type="checkbox"
  :value="value"
>

這樣,現在看起來已經可以順利運作了。

單個使用:

AtomicCheckbox 單個使用

多個一起使用:

AtomicCheckbox 多個使用

加上 trueValuefalseValue 試試看效果:

<input
  v-model="modelValueWritable"
  class="atomic-checkbox__input"
  ...
  :true-value="trueValue"
  :false-value="falseValue"
>

trueValuefalseValue

加上 trueValuefalseValue 後看起來一切正常。

<template>
  <AtomicCheckbox
    label="我同意幫 Alex Liu 分享他的鐵人賽文章給所有的親朋好友"
    message="必填"
    true-value="分享拉!哪次不分享"
    false-value="我只想要自己看"
  />
</template>

AtomicCheckbox trueValue 與 falseValue

但沒使用 trueValuefalseValue 的版本卻壞掉了。

AtomicCheckbox trueValue 與 falseValue 失敗

要解決這個問題有兩個方法:

  1. trueValuefalseValue 的預設值應該是 truefalse
  2. trueValuefalseValue 沒有設定時,不綁定到 <input> 上。

第一種方法相對簡單,我們只需要在 props 中設定預設值即可。

const props = withDefaults(defineProps<AtomicCheckboxProps>(), {
  // ...
  trueValue: true,
  falseValue: false,
});

不過這種做法在開發人員沒有使用 trueValuefalseValue 時,渲染出來的 DOM 會長這樣:

trueValue 與 falseValue 預設值為 true 與 false 的結果

這完全不影響使用結果,但我自己會希望在開發人員沒有使用 trueValuefalseValue 時,不要渲染這些屬性。因此,我可能會選擇第二種方法。

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"
>

這樣,當 trueValuefalseValue 沒有被設定時,就不會綁定到 <input> 上。當然,如果第一種結果已經可以接受的話,這是個相對簡便的作法。

indeterminate 狀態

接著我們加上 indeterminate 的支援。

AtomicCheckbox indeterminate

在 Vue 裡面,如果 Checkbox 的 indeterminatetrue,不論 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>indeterminatetrue 時,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-ifv-else-ifv-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 完成

總結

<AtomicCheckbox> 是一個簡單的元件,它不需要處理 v-model 的修飾符,也不需處理太多的邏輯,我們只需要處理 trueValuefalseValueindeterminate 的狀態即可。

在樣式部分,我們可以使用 CSS 來控制 Icon 的顯示,也可以使用 JavaScript 來控制 Icon 的顯示。使用 CSS 的好處是我們可以讓瀏覽器自行判斷要顯示的 Icon,適合用在像是使用 icon font 的設計;在使用 SVG 作為 Icon 的情境下,使用 v-ifv-else 來控制顯示可以讓我們的 HTML 結構更加精簡、乾淨。這兩種方式各有優缺點,可以根據自己的需求來選擇。

參考資料


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

尚未有邦友留言

立即登入留言