![[為你自己寫 Vue Component] AtomicRadio](https://ithelp.ithome.com.tw/upload/images/20241005/20120484pOgMi45wNJ.png)
Radio 是一種表單控制元件,通常用於使用者在一組選項中選取一個。在 UI 呈現上,通常顯示為圓形按鈕,當用戶選中時,按鈕會顯示填滿狀態,表示該選項已被選中。
Radio 設計為單選,因此可以應用在像是性別、行動支付方式或是主題色的顯示設定等。



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

<template>
  <ElRadioGroup v-model="radio1">
    <ElRadio value="1" size="large">Option 1</ElRadio>
    <ElRadio value="2" size="large">Option 2</ElRadio>
  </ElRadioGroup>
  <ElRadioGroup v-model="radio1">
    <ElRadio value="1">Option 1</ElRadio>
    <ElRadio value="2">Option 2</ElRadio>
  </ElRadioGroup>
  <ElRadioGroup v-model="radio1">
    <ElRadio value="1" size="small">Option 1</ElRadio>
    <ElRadio value="2" size="small">Option 2</ElRadio>
  </ElRadioGroup>
</template>
Element Plus 提供了 <ElRadioGroup> 和 <ElRadio> 兩個元件,<ElRadioGroup> 用來包裹 <ElRadio>,並且可以選擇在這裡統一設定 v-model 來控制選中的值。<ElRadio> 可以設定 value 來設定該選項的值,並且可以設定 size 來控制 Radio 的大小。
Vuetify

<template>
  <VRadioGroup>
    <VRadio label="Radio One" value="one"></VRadio>
    <VRadio label="Radio Two" value="two"></VRadio>
    <VRadio label="Radio Three" value="three"></VRadio>
  </VRadioGroup>
</template>
Vuetify 一樣提供了兩個元件來組合使用,<VRadioGroup> 和 <VRadio>,<VRadioGroup> 用來包裹 <VRadio>,一樣可以選擇在這裡統一設定 v-model 來控制選中的值。<VRadio> 可以設定 label 來設定該選項的標籤,並且可以設定 value 來設定該選項的值。
Nuxt UI

const options = [
  {
    value: 'email',
    label: 'Email',
  },
  {
    value: 'sms',
    label: 'Phone (SMS)',
  },
  {
    value: 'push',
    label: 'Push notification',
  }
]
const selected = ref('sms')
<template>
  <URadioGroup
    v-model="selected"
    legend="Choose something"
    :options="options"
  />
</template>
雖然範例沒有特別提到,但是 Nuxt UI 也提供了 <URadioGroup> 和 <URadio> 兩個元件,比較特別的是,<URadioGroup> 可以直接透過 :options 來設定選項,並且可以透過 legend 來設定 Radio Group 的標題。這樣的設計方式看起來非常簡潔,不過如果需要比較細緻的 UI 調整,就會比較具挑戰性。
從 Element Plus、Vuetify 和 Nuxt UI 的 Radio 元件設計中,我們發現 Radio 元件通常會提供兩個元件來組合使用。主要原因是 Radio 元件的使用情境是「在多個選項之中選取一個」,因此它與 Checkbox 不同,Radio 通常不會單獨存在。也因此,如果有一個 Radio Group 元件,就可以讓開發人員在使用時,少寫一些程式碼。
綜合以上並結合自身經驗,我們統整出 <AtomicRadio> 的功能:
v-model 來雙向綁定選擇的值。label 來設定 Radio 的文字。labelPlacement 來設定 Radio 文字的位置。value 來設定多個一起使用時,選中時的值。disabled 來設定 Radio 的禁用狀態。color 來設定 Radio 的顏色。並且同時提供 <AtomicRadioGroup> 元件,除了 label 與 value 外,<AtomicRadioGroup> 可以接受與 <AtomicRadio> 相同的屬性。
使用結構如下:
<template>
  <AtomicRadio
    v-model="selected"
    color="primary"
    label="Modern Web"
    name="radio"
    value="1"
  />
  <AtomicRadio
    v-model="selected"
    color="primary"
    label="JavaScript"
    name="radio"
    value="2"
  />
</template>
<template>
  <AtomicRadioGroup
    v-model="selected"
    color="primary"
    name="radio"
  >
    <AtomicRadio label="Modern Web" value="1" />
    <AtomicRadio label="JavaScript" value="2" />
  </AtomicRadioGroup>
</template>
首先,我們將需求中提到的功能整理成 props 的介面,我們會需要下列屬性:
| 屬性 | 型別 | 預設值 | 說明 | 
|---|---|---|---|
| modelValue | any | 選中時的值 | |
| label | string | Radio 的 label 文字 | |
| labelPlacement | left,right,top,bottom | right | Radio 的 label 文字的位置 | 
| value | any | true | 該選項的值 | 
| disabled | boolean | false | 是否禁用 Radio | 
| color | primary,success,warning,danger,info | primary | Radio 的顏色 | 
interface AtomicRadioProps {
  modelValue?: any;
  value?: any;
  name?: string;
  label?: string;
  labelPlacement?: 'top' | 'left' | 'right' | 'bottom';
  hideLabel?: boolean;
  color?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
  message?: string;
  disabled?: boolean;
  error?: boolean;
}
interface AtomicRadioEmits {
  (event: 'update:modelValue', value: any): void;
}
const props = withDefaults(defineProps<AtomicRadioProps>(), {
  modelValue: undefined,
  value: undefined,
  name: undefined,
  label: undefined,
  labelPlacement: 'right',
  color: 'primary',
  message: undefined,
});
const emit = defineEmits<AtomicRadioEmits>();
先處理基本 UI,我們以 <AtomicFormLabelField> 作為包裝元件,剩下的程式碼與模板非常簡單:
const modelValueWritable = computed({
  get() {
    return props.modelValue;
  },
  set(value) {
    emit('update:modelValue', value);
  },
});
<template>
  <AtomicFormLabelField
    class="atomic-radio"
    :class="{
      'atomic-radio--disabled': disabled,
      [`atomic-radio--${color}`]: !!color,
    }"
    :hide-label="hideLabel"
    :label="label"
    :label-placement="labelPlacement"
  >
    <input
      v-model="modelValueWritable"
      class="atomic-radio__input"
      :disabled="disabled"
      :name="name"
      type="radio"
      :value="value"
    >
  </AtomicFormLabelField>
</template>
我們可以使用在 <AtomicCheckbox> 裡面提到的偽元素來實作 <AtomicRadio> 的樣式:
<AtomicFormLabelField class="atomic-radio">
  <input
    v-model="modelValueWritable"
    class="atomic-radio__input"
    :disabled="disabled"
    :name="name"
    type="radio"
    :value="value"
  >
  <svg class="atomic-radio__icon atomic-radio__icon--unpicked">
    <!--  -->
  </svg>
  <svg class="atomic-radio__icon atomic-radio__icon--picked">
    <!--  -->
  </svg>
</AtomicFormLabelField>
我們把選取(picked)和未選取(unpicked)的 SVG 放在 <input> 標籤後面,這樣我們就可以透過 CSS 的偽元素來控制 SVG 的顯示與隱藏。
.atomic-radio {
  &__input {
    @include sr-only;
  }
  &__input:checked ~ .atomic-radio__icon--unpicked,
  &__input:not(:checked) ~ .atomic-radio__icon--picked {
    display: none;
  }
}
這樣我們幾乎就完成了 <AtomicRadio> 的實作,接下來我們要實作 <AtomicRadioGroup>。
我們先對標一下我們期望的 <AtomicRadioGroup> 應該肩負的功能:
label 與 value 外,<AtomicRadioGroup> 可以接受與 <AtomicRadio> 相同的屬性。modelValue 外,設定在 <AtomicRadio> 的 props 權重比 <AtomicRadioGroup> 高。定義 <AtomicRadioGroup> 的 props:
export interface AtomicRadioGroupProps {
  modelValue?: any;
  name?: string;
  labelPlacement?: 'top' | 'left' | 'right' | 'bottom';
  hideLabel?: boolean;
  color?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
  disabled?: boolean;
  error?: boolean;
}
interface AtomicRadioGroupEmits {
  (event: 'update:modelValue', value: any): void;
}
const props = withDefaults(defineProps<AtomicRadioGroupProps>(), {
  modelValue: undefined,
  name: undefined,
  labelPlacement: undefined,
  color: undefined,
});
const emit = defineEmits<AtomicRadioGroupEmits>();
為了不覆蓋到 <AtomicRadio> 的 props,在 <AtomicRadioGroup> 中我們所有的 props 預設值都為 undefined。
接下來,我們這裡採用 provide / inject 的方式來實作,這樣我們就可以在 <AtomicRadio> 裡面直接透過 inject 來取得 <AtomicRadioGroup> 提供的 modelValue 與 props。
// Script
interface AtomicRadioContext {
  modelValue: Ref<any>;
  props: AtomicRadioGroupProps;
};
export const RADIO_INJECT_KEY: InjectionKey<AtomicRadioContext> = Symbol();
// Setup Script
const modelValueLocal = ref(props.modelValue ?? false);
const modelValueWritable = computed({
  get() {
    return props.modelValue ?? modelValueLocal.value;
  },
  set(value) {
    emit('update:modelValue', value);
    modelValueLocal.value = value;
  },
});
provide(RADIO_INJECT_KEY, {
  modelValue: modelValueWritable,
  props,
});
加上模板 <AtomicRadioGroup> 就完成了:
<template>
  <slot name="default" />
</template>
再來,我們回到 <AtomicRadio> 的實作,透過 inject 來取得 <AtomicRadioGroup> 提供的 context,並調整原本的實作。
const context = inject(RADIO_INJECT_KEY, null);
modelValueWritable 需要考慮到 context 是否存在,如果存在就使用 context.modelValue,否則就使用 props.modelValue:
const modelValueWritable = computed({
  get() {
    if (context) return context.modelValue.value;
    return props.modelValue;
  },
  set(value) {
    if (context) {
      context.modelValue.value = value;
      return;
    }
    emit('update:modelValue', value);
  },
});
到這裡我們已經可以直接在 <AtomicRadioGroup> 使用 v-model 來控制 <AtomicRadio> 的選中狀態了。
接著處理 props 與 context.props 的合併。
const mergedProps = computed(() => {
  if (!context) return props;
  return {
    ...context.props,
    ...props,
  };
});
我們不能像這樣直接合併,考慮下列 context.props 與 props 合併後的結果:
context.props
{
  modelValue: 'Modern Web',
  name: 'group',
  labelPlacement: undefined,
  hideLabel: undefined,
  color: 'success',
  disabled: undefined,
  error: undefined,
}
props
{
  modelValue: undefined,
  value: 'Modern Web',
  name: undefined,
  label: 'Modern Web',
  labelPlacement: 'right',
  color: 'primary',
  message: undefined,
}
合併後的物件
{
  modelValue: 'Modern Web',
  value: 'Modern Web',
  name: undefined,
  label: 'Modern Web',
  labelPlacement: 'right',
  hideLabel: undefined,
  color: 'primary',
  disabled: undefined,
  error: undefined,
  message: undefined,
}
以 name 為例,我們預期合併出來的結果是 group,但最後得到會是 undefined,所以我們不能單純合併它,需要一些調整與加工。
首先,我們得讓 <AtomicRadio> 的每個 prop 預設值都是 undefined。
const props = withDefaults(defineProps<AtomicRadioProps>(), {
  modelValue: undefined,
  value: undefined,
  name: undefined,
  label: undefined,
  labelPlacement: undefined,
  color: undefined,
  message: undefined,
});
並且在 mergedProps 中逐個屬性合併。
const mergedProps = computed(() => {
  if (!context) return props;
  return {
    name: props.name ?? context.props.name,
    labelPlacement:
      props.labelPlacement ?? context.props.labelPlacement ?? 'right',
    hideLabel: props.hideLabel ?? context.props.hideLabel,
    color: props.color ?? context.props.color ?? 'primary',
    disabled: props.disabled ?? context.props.disabled,
    error: props.error ?? context.props.error,
  };
});
到這裡就完成了我們的 <AtomicRadio> 與 <AtomicRadioGroup> 的實作了!
<AtomicRadio> 是一個非常簡單實作的元件,只要處理好雙向綁定與 CSS 樣式的設定,很輕鬆就能完成。
由於 Radio 幾乎不會單獨存在,所以我們也同時實作了 <AtomicRadioGroup>。透過 provide / inject,我們能從 <AtomicRadio> 裡面取得 <AtomicRadioGroup> 上的資料。
在開發中或是與設計師的溝通過程中,偶爾會發現還是會有人將 Checkbox 與 Radio 混為一談,將 Radio 當作 Checkbox 使用。因此在文章中我們也多次提醒,Radio 是在多個選項中只能選取一個,而 Checkbox 是可以選取多個選項。
<AtomicRadio> 原始碼:AtomicRadio.vue