iT邦幫忙

2024 iThome 鐵人賽

DAY 21
2
Modern Web

為你自己寫 Vue Component系列 第 21

[為你自己寫 Vue Component] AtomicFormField

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicFormField

在一個專案當中,標單數入元件通常會有統一的外觀風格,讓整個系統看起來更一致、整齊。<AtomicFormField> 是用來渲染表單欄位的元件。它是 <input><select> 等元素的包裝器(Wrapper),提供表單欄位一致的外觀和使用體驗。

<AtomicFormField> 作為各種元件的包裝器,在後面將實作的 <AtomicTextField><AtomicTextarea><AtomicSelect> 等元件內部都會直接使用 <AtomicFormField>。這部分與多數的 UI Library 不太一樣。在 UI Library 的設計上,大多會盡可能將能拆開的元件拆開,這樣使用者就有選擇單獨使用或組合成新元件的空間。因此,如果目標是設計開源的 UI Library,會建議 FormField 歸 FormField,TextField 歸 TextField 更好一些。

AtomicFormField 系列元件

元件分析

元件架構

AtomicFormField 元件架構

  1. Label:Field 的標籤。
  2. Control:Field 的表單控制區塊,我們可以在裡面放 <input><textarea><select>
  3. Message:欄位的說明文字,可以用來顯示欄位的說明或是錯誤訊息。

功能設計

在開始實作前,我們先研究各個 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 設定欄位為必填。還可以透過 descriptionhelp 分別設定欄位的說明文字與提示文字,透過 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,並且將 labelmessage 顯示在適當的結構。

為了讓 labelmessage 的使用更有彈性,我們可以讓使用者透過 slot 的方式放入 labelmessage

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>

然而 labelmessage 並不總是存在,我們可以讓它們在不存在時不顯示,我們可以考慮使用 v-if 或是 v-show 來處理。

在選用哪一種方式時,我自己會依據:如果是在網站操作過程中容易變動的,會使用 v-show,如果是相對穩定的存在或不存在,則會選用 v-if

在這裡 label 我會使用 v-ifmessage 選用 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 區塊對齊,這樣畫面會比較整齊。

AtomicFormField 的 Label 置中

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

加上 forid 屬性,我們就可以讓 <label><input> 有對應關係。

<label for="name">姓名</label>
<input id="name" type="text" />
<label for="email">Email</label>
<input id="email" type="text" />

除此之外,加上 forid 屬性後,當我們點擊 <label> 時,瀏覽器會幫我們自動將焦點轉移到對應的 <input> 上。

點擊 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"
/>

ARIA 屬性

<AtomicFormField> 中我們可以透過 aria-describedby 來指定 messageid,這樣螢幕閱讀器就可以將 message 的內容讀出來。

這裡我們一樣透過 default slot 傳遞 messageid

<!-- 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-ifv-show 選用的參考依據。

儘管我不將 <AtomicFormField> 定位於基礎元件,但因為這個元件的目的就是要讓其他元件能夠共用 UI,因此我們在這裡設計了一些 CSS 變數來讓其他元件可以使用,這樣我們就可以在未來的開發中更容易地調整 UI。

最後在無障礙的部分,我們讓 <label> 與 Control 區塊建立對應關係,這樣不但可以讓使用輔助技術的人更容易使用,也讓我們自己在操作時更加方便。

參考資料


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

尚未有邦友留言

立即登入留言