iT邦幫忙

2024 iThome 鐵人賽

DAY 27
2
Modern Web

為你自己寫 Vue Component系列 第 27

[為你自己寫 Vue Component] AtomicRadio

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicRadio

Radio 是一種表單控制元件,通常用於使用者在一組選項中選取一個。在 UI 呈現上,通常顯示為圓形按鈕,當用戶選中時,按鈕會顯示填滿狀態,表示該選項已被選中。

Radio 設計為單選,因此可以應用在像是性別、行動支付方式或是主題色的顯示設定等。

使用 Radio 選擇付款方式

使用 Radio 選擇 Twitter 的主題設定

元件分析

元件架構

AtomicRadio 元件架構

  1. Radio(Picked):Radio 選中時的樣式。
  2. Radio(Unpicked):Radio 未選中時的樣式。
  3. Label:Radio 的文字標籤。

功能設計

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

Element Plus

Element Plus Radio

<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

Vuetify Radio

<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

Nuxt UI Radio

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> 元件,除了 labelvalue 外,<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>();

AtomicRadio

先處理基本 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

我們先對標一下我們期望的 <AtomicRadioGroup> 應該肩負的功能:

  • 除了 labelvalue 外,<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> 提供的 modelValueprops

// 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> 的選中狀態了。

接著處理 propscontext.props 的合併。

const mergedProps = computed(() => {
  if (!context) return props;

  return {
    ...context.props,
    ...props,
  };
});

我們不能像這樣直接合併,考慮下列 context.propsprops 合併後的結果:

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 是可以選取多個選項。

參考資料


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

尚未有邦友留言

立即登入留言