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