在一個專案當中,標單數入元件通常會有統一的外觀風格,讓整個系統看起來更一致、整齊。<AtomicFormField>
是用來渲染表單欄位的元件。它是 <input>
、<select>
等元素的包裝器(Wrapper),提供表單欄位一致的外觀和使用體驗。
<AtomicFormField>
作為各種元件的包裝器,在後面將實作的 <AtomicTextField>
、<AtomicTextarea>
與 <AtomicSelect>
等元件內部都會直接使用 <AtomicFormField>
。這部分與多數的 UI Library 不太一樣。在 UI Library 的設計上,大多會盡可能將能拆開的元件拆開,這樣使用者就有選擇單獨使用或組合成新元件的空間。因此,如果目標是設計開源的 UI Library,會建議 FormField 歸 FormField,TextField 歸 TextField 更好一些。
<input>
、<textarea>
、<select>
。在開始實作前,我們先研究各個 UI Library 的 Field 相關元件是如何設計的。
Element Plus
<template>
<ElForm :model="form" label-width="auto" style="max-width: 600px">
<ElFormItem label="Activity name">
<ElInput v-model="form.name" />
</ElFormItem>
<ElFormItem label="Activity zone">
<ElSelect v-model="form.region" placeholder="please select your zone">
<ElOption label="Zone one" value="shanghai" />
<ElOption label="Zone two" value="beijing" />
</ElSelect>
</ElFormItem>
</ElForm>
</template>
在 Element Plus 中最接近的元件是 <ElFormItem>
。<ElFormItem>
與 <ElForm>
整合後功能包山包海,甚至與表單欄位驗證結合在一起。
撇除各式各樣的功能,與 UI 相關的設定有:可以透過 label
設定標籤內容,透過 label-width
設定標籤寬度,透過 label-position
設定標籤位置。也可以透過 error
設定錯誤訊息及 required
設定欄位為必填。
Nuxt UI
<template>
<UFormGroup label="Email" required>
<UInput placeholder="you@example.com" icon="i-heroicons-envelope" />
</UFormGroup>
</template>
Nuxt UI 的 <UFormGroup>
可以透過 label
設定欄位標籤內容,透過 required
設定欄位為必填。還可以透過 description
與 help
分別設定欄位的說明文字與提示文字,透過 error
標記欄位是否有錯誤。
Element Plus 的 <ElFormItem>
與 Nuxt UI 的 <UFormGroup>
都是將標籤、控制區塊、說明文字、錯誤訊息整合在一起的元件。
綜合以上並結合自身經驗,我們統整出 <AtomicFormField>
的功能:
label
設定欄位標籤內容。labelPlacement
設定欄位標籤位置。labelWidth
設定欄位標籤寬度。hideLabel
設定是否隱藏標籤。message
設定欄位提示訊息。error
表示欄位是否有錯誤,如果有錯誤 message
則表示錯誤訊息。required
設定是否顯示欄位必填標記。disabled
設定欄位是否禁用。readonly
設定欄位是否唯讀。使用結構如下:
<template>
<AtomicFormField
label="姓名"
labelPlacement="top"
labelWidth="fit-content"
>
<input />
</AtomicFormField>
</template>
另外在 Element Plus 中,<ElFormItem>
還接受了欄位驗證功能設定的功能,這個部分在我們的 <AtomicFormField>
中並不會實作。
首先,我們將需求中提到的功能整理成 props
的介面,我們會需要下列屬性:
名稱 | 型別 | 預設值 | 說明 |
---|---|---|---|
label | string , undefined |
undefined |
欄位標籤 |
labelPlacement | top , left |
left |
欄位標籤位置 |
labelWidth | string , number |
fit-content |
欄位標籤寬度 |
hideLabel | boolean |
false |
是否隱藏欄位標籤 |
message | string , undefined |
undefined |
欄位提示訊息 |
error | boolean |
false |
欄位是否有錯誤 |
required | boolean |
false |
欄位是否必填 |
disabled | boolean |
false |
欄位是否禁用 |
readonly | boolean |
false |
欄位是否唯讀 |
export interface AtomicFormFieldProps {
label?: string;
labelPlacement?: 'top' | 'left';
labelWidth?: string | number;
hideLabel?: boolean;
message?: string;
error?: boolean;
required?: boolean;
disabled?: boolean;
readonly?: boolean;
}
const props = withDefaults(defineProps<AtomicFormFieldProps>(), {
label: undefined,
labelPlacement: 'left',
labelWidth: 'fit-content',
message: undefined,
});
<AtomicFormField>
的元件定位是一個包裝元件,他接收了各種設定,並將這些設定套用到內部的結構上。而未來使用的元件同樣需要接收這些設定,為了讓其他元件元件可以把 <AtomicFormField>
需要的 props 挑出來並且傳下去,我們可以實作一個 useFormFieldProps
的 Composable API,他會將 props 物件中 <AtomicFormField>
需要的 props 挑出來。
function pick<T extends Record<string, any>, K extends keyof T>(
obj: T,
keys: K[]
) {
return keys.reduce((acc, key) => {
if (obj[key] !== undefined) acc[key] = obj[key];
return acc;
}, {} as Pick<T, K>);
}
export function useFormFieldProps(
props: MaybeRefOrGetter<AtomicFormFieldProps>
) {
return computed<AtomicFormFieldProps>(() =>
pick(toValue(props), [
'label',
'labelPlacement',
'labelWidth',
'hideLabel',
'message',
'error',
'required',
'disabled',
'readonly',
])
);
}
這樣在其他元件當中,只要這樣使用,就可以把 <AtomicFormField>
需要的 props 挑出來並且傳遞下去。
const fieldProps = useFormFieldProps(() => props);
<template>
<AtomicFormField v-bind="fieldProps">
<!-- 略 -->
</AtomicFormField>
</template>
<AtomicFormField>
雖然會讓很多元件共同使用,但它的定位是包裝元件而非基礎元件,因此我們不會承攬其他元件的任何功能,只會專注在樣式設計上。因此我們盡可能只專注地處理好 HTML 跟 CSS 就好。
<template>
<div
class="atomic-form-field"
:class="{
'atomic-form-field--error': error,
'atomic-form-field--readonly': readonly,
'atomic-form-field--disabled': disabled,
'atomic-form-field--required': !hideLabel && required,
'atomic-form-field--hide-label': hideLabel,
[`atomic-form-field--label-${labelPlacement}`]: !!labelPlacement,
}"
:style="!hideLabel
? {
'--field-label-width': toUnit(labelWidth)
}
: undefined
"
>
<div class="atomic-form-field__container">
<div class="atomic-form-field__label">
<label class="atomic-form-field__label-content">
{{ label }}
</label>
</div>
<div class="atomic-form-field__content">
<div class="atomic-form-field__control">
<slot name="default" />
</div>
<div class="atomic-form-field__message">
{{ message }}
</div>
</div>
</div>
</div>
</template>
結構上非常單純,我們保留了 default slot 的位置給其他元件放入各自的 UI,並且將 label
與 message
顯示在適當的結構。
為了讓 label
與 message
的使用更有彈性,我們可以讓使用者透過 slot 的方式放入 label
與 message
。
label
<div class="atomic-form-field__label">
<label class="atomic-form-field__label-content">
<slot
:label="label"
name="label"
>
<span>
{{ label }}
</span>
</slot>
</label>
</div>
message
<div class="atomic-form-field__message">
<slot
:error="error"
:message="message"
name="message"
>
{{ message }}
</slot>
</div>
然而 label
與 message
並不總是存在,我們可以讓它們在不存在時不顯示,我們可以考慮使用 v-if
或是 v-show
來處理。
在選用哪一種方式時,我自己會依據:如果是在網站操作過程中容易變動的,會使用 v-show
,如果是相對穩定的存在或不存在,則會選用 v-if
。
在這裡 label
我會使用 v-if
,message
選用 v-show
。
label
<div
v-if="label || $slots.label"
class="atomic-form-field__label"
>
<label class="atomic-form-field__label-content">
<slot
:label="label"
name="label"
>
<span>
{{ label }}
</span>
</slot>
</label>
</div>
message
<div
v-show="message || $slots.message"
:id="`${id}-message`"
class="atomic-form-field__message"
>
<slot
:error="error"
:message="message"
name="message"
>
{{ message }}
</slot>
</div>
這樣一來 HTML 結構大致完成。
不過我們在前面有一個 hideLabel
的設定用來隱藏 Label 結構,但我們並沒有使用 v-if
來處理,這是因為我們希望在 hideLabel
設定為 true
時,Label 結構仍然存在,只是不顯示而已。
更正確地說是:視覺上不存在。
@mixin sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.atomic-form-field {
&--hide-label &__label {
@include sr-only;
}
}
在 <AtomicBreadcrumb>
中我們有使用到 sr-only
這個 mixin,這個 mixin 是用來隱藏元素但保留在 DOM 中,這樣可以讓輔助技術(例如螢幕閱讀器)可以正確地讀取到這個元素。
還有 labelPlacement
的設定,我們可以透過 CSS 變數來設定 Label 的位置。在不動到架構的情況下,我們可以使用 CSS 的 Flex 來調整 Label 的位置。
.atomic-form-field {
&__container {
display: flex;
width: 100%;
}
&--label-left &__container {
align-items: stretch;
column-gap: 8px;
}
&--label-top &__container {
flex-direction: column;
row-gap: 6px;
}
}
隨著 labelPlacement
的不同我們的 Label 區塊也有一些細節需要調整,Label 在上方的畫面會比較單純,但如果 Label 在左側時,我們需要盡可能讓 Label 與 Control 區塊對齊,這樣畫面會比較整齊。
.atomic-form-field {
--field-height: 38px;
&--label-left &__label {
width: var(--field-label-width);
line-height: var(--field-height);
}
}
在這裡我們可以使用 line-height: var(--field-height)
來讓 Label 垂直置中,這樣如果旁邊的 Control 區塊高度剛好也等於 --field-height
,整個欄位就會水平置中。而就算遇到像是 <AtomicTextarea>
這種高度不固定的元件,我們也可以讓所有的 Label 區塊有一致的高度。
這裡的高度使用 CSS 變數是為了讓未來使用 <AtomicFormField>
的元件內部可以透過這個變數來取得統一的高度。如果遇到 UI 需要統一調整高度時,我們只要在 <AtomicFormField>
裡面調整即可。
在網頁切版時,我們要讓 <label>
與 <input>
有對應關係,這樣輔助技術才能清楚地辨識每個 <label>
分別對應到的 <input>
、<textarea>
與 <select>
是什麼。
像這樣,輔助技術或是搜尋引擎根本不會知道這個 <label>
是對應到哪個 <input>
。
<label>姓名</label>
<input type="text" />
<label>Email</label>
<input type="text" />
加上 for
與 id
屬性,我們就可以讓 <label>
與 <input>
有對應關係。
<label for="name">姓名</label>
<input id="name" type="text" />
<label for="email">Email</label>
<input id="email" type="text" />
除此之外,加上 for
與 id
屬性後,當我們點擊 <label>
時,瀏覽器會幫我們自動將焦點轉移到對應的 <input>
上。
在這裡 <AtomicFormField>
儘管沒有包含任何表單控制的元素,但我們還是可以將每個 Field 的 id
準備好,並從 default slot 傳出去,這樣我們之後在實作 <AtomicTextField>
、<AtomicTextarea>
與 <AtomicSelect>
時就可以直接使用。
const attrs = useAttrs();
const _id = `field-${Math.round(Math.random() * 1e5)}`;
const id = computed(() => (attrs.id as string) || _id);
<!-- Label -->
<label :for="id">
<slot
:label="label"
name="label"
>
{{ label }}
</slot>
</label>
<!-- Default Slot -->
<slot
:id="id"
name="default"
/>
在 <AtomicFormField>
中我們可以透過 aria-describedby
來指定 message
的 id
,這樣螢幕閱讀器就可以將 message
的內容讀出來。
這裡我們一樣透過 default slot 傳遞 message
的 id
。
<!-- Message -->
<div
v-show="message || $slots.message"
:id="`${id}-message`"
class="atomic-form-field__message"
>
<slot
:error="error"
:message="message"
:id="`${id}-message`"
name="message"
>
{{ message }}
</slot>
</div>
<!-- Default Slot -->
<slot
:id="id"
:describedby="`${id}-message`"
name="default"
/>
<AtomicFormField>
是用來包裝表單控制元件的元件,在這個元件的實作當中我們專注在模板與 CSS 的設計上,藉此機會也分享了我對於 v-if
與 v-show
選用的參考依據。
儘管我不將 <AtomicFormField>
定位於基礎元件,但因為這個元件的目的就是要讓其他元件能夠共用 UI,因此我們在這裡設計了一些 CSS 變數來讓其他元件可以使用,這樣我們就可以在未來的開發中更容易地調整 UI。
最後在無障礙的部分,我們讓 <label>
與 Control 區塊建立對應關係,這樣不但可以讓使用輔助技術的人更容易使用,也讓我們自己在操作時更加方便。
<AtomicFormField>
原始碼:AtomicFormField.vue