iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
自我挑戰組

30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System系列 第 16 篇

Day 16: 把表單元件組起來:第一個完整 Form Pattern

  • 分享至 

  • xImage
  •  

前幾天我們陸續完成了:

  • Input
  • Field
  • Textarea
  • Checkbox
  • Radio Group
  • Switch
  • Select

單獨看,每個元件都已經可以正常使用。

但真正的系統不會只有:

<Input />

或:

<Select />

而是會長得像:

姓名
[                    ]

Email
[                    ]
系統通知將寄送至此信箱。

居住城市
[ 請選擇城市       ▼ ]

個人簡介
[                    ]
[                    ]

偏好的聯絡方式
● Email
○ 電話

Email 通知              [ ON ]

☐ 我已閱讀並同意使用條款

[ 儲存設定 ]

所以今天先不新增元件。

來把前五天做的 Form Components,真正組成第一個 Form Pattern。


Component 和 Pattern 有什麼不同?

前面做的 Input、Select、Checkbox,都可以視為 Component。

例如:

<Input />

它只需要處理自己的:

Default
Focus
Disabled
Invalid

但一張表單需要處理的是「元件之間的關係」。

例如:

Field
├── Label
├── Input
├── Description
└── Error

甚至:

Form
├── Input Field
├── Select Field
├── Textarea Field
├── Radio Group
├── Switch
├── Checkbox
└── Submit Button

這時候我們處理的已經不是單一 Component,而是一個 Pattern。


1. 先建立 Form 的基本結構

今天做的是一張「個人資料設定」表單:

<form>
  <FieldGroup>
    {/* fields */}
  </FieldGroup>
</form>

這也是 FieldGroup 第一次真正開始負責整張表單的排列。

概念上:

Form
└── FieldGroup
    ├── Field
    ├── Field
    ├── Field
    ├── FieldSet
    ├── Field
    └── Field

前面建立的 Field system 開始派上用場了。


2. Required 不只是加一顆紅色星號

姓名是必填欄位:

<Field>
  <FieldLabel htmlFor="profile-name">
    姓名
    <span
      aria-hidden="true"
      className="text-destructive"
    >
      *
    </span>
  </FieldLabel>

  <Input
    id="profile-name"
    name="name"
    required
    autoComplete="name"
    placeholder="請輸入姓名"
  />
</Field>

畫面上:

姓名 *
[ 請輸入姓名 ]

這裡其實有兩件不同的事情。

紅色 *:

<span aria-hidden="true">*</span>

負責視覺提示。

真正告訴瀏覽器「這個欄位是必填」的則是:

required

所以:

*
→ 給視覺使用者看的提示

required
→ 欄位真正的 required semantics

不能只有紅色星號,卻忘了真正的 required。


3. Description 不是裝飾文字

Email 除了 Label,還有額外說明:

<Field>
  <FieldLabel htmlFor="profile-email">
    Email
  </FieldLabel>

  <Input
    id="profile-email"
    type="email"
    aria-describedby="profile-email-description"
  />

  <FieldDescription id="profile-email-description">
    系統通知將寄送至此信箱。
  </FieldDescription>
</Field>

關係是:

Email
[ example@example.com ]
系統通知將寄送至此信箱。
          ↑
          │
aria-describedby

也就是說,Description 不只是「剛好放在 Input 下面的小字」。

它和 Control 之間應該存在真正的語意關係。


4. Select 也遵守同一套 Field Pattern

昨天完成的 Select 今天可以直接放進 Field:

<Field>
  <FieldLabel htmlFor="profile-city">
    居住城市
  </FieldLabel>

  <Select>
    <SelectTrigger
      id="profile-city"
      aria-describedby="profile-city-description"
    >
      <SelectValue placeholder="請選擇城市" />
    </SelectTrigger>

    <SelectContent>
      <SelectItem value="taipei">台北</SelectItem>
      <SelectItem value="taoyuan">桃園</SelectItem>
      <SelectItem value="taichung">台中</SelectItem>
    </SelectContent>
  </Select>

  <FieldDescription id="profile-city-description">
    請選擇目前主要居住的城市。
  </FieldDescription>
</Field>

這時候可以發現一件很重要的事:

Field 並不需要知道裡面到底是 Input 還是 Select。

Field
├── Label
├── Control
├── Description
└── Error

其中 Control 可以替換成:

Input
Textarea
Select
Checkbox
Radio
Switch

這就是前面建立 Field abstraction 的價值。


5. 一組選項要有「這組是在選什麼」

Radio Group 又不太一樣。

例如:

偏好的聯絡方式

● Email
○ 電話

這裡除了每一個 Radio 的 Label:

Email
電話

還需要描述:

這整組 Radio 到底是在選什麼?

因此使用:

<FieldSet>
  <FieldLegend>
    偏好的聯絡方式
  </FieldLegend>

  <RadioGroup defaultValue="email">
    ...
  </RadioGroup>
</FieldSet>

結構變成:

fieldset
│
├── legend
│   └── 偏好的聯絡方式
│
└── RadioGroup
    ├── Email
    └── 電話

這比單純在 Radio 上面放一個 <div> 標題更具有表單語意。


6. Checkbox 和 Switch 看起來很像,責任卻不同

完整表單裡也同時放入 Checkbox 與 Switch。

Switch:

Email 通知                     [ ON ]
接收重要的帳號與系統通知。

代表:

這個功能現在是開還是關?

Checkbox:

☐ 我已閱讀並同意使用條款

代表:

我是否選擇/同意這件事情?

因此即使兩個元件都有:

true / false

也不能因為資料型別相同,就隨便互換。

元件選擇應該反映使用者正在做的事情,而不是只看程式裡存的是不是 Boolean。


7. Error 需要同步三個層級

真正開始做 Validation 後,一個錯誤狀態不只有「顯示紅字」。

例如 Email 驗證失敗:

<Field data-invalid={hasError}>

Control:

<Input
  aria-invalid={hasError}
  aria-describedby="email-description email-error"
/>

Error:

{hasError && (
  <FieldError id="email-error">
    請輸入有效的 Email。
  </FieldError>
)}

可以拆成:

Validation Result
       │
       ↓
   hasError
       │
       ├── Field
       │   data-invalid
       │
       ├── Control
       │   aria-invalid
       │
       └── FieldError
           顯示錯誤訊息

三者的責任不同:

狀態 負責
data-invalid Field 的視覺狀態
aria-invalid Control 的無障礙語意
FieldError 顯示實際錯誤訊息

所以:

<Field data-invalid="true">

本身不應該自動產生:

請輸入有效的 Email。

因為 Field 根本不知道「錯在哪裡」。

錯誤內容仍然應該由 Validation Logic 決定。


8. Error Message 也要和 Control 建立關係

假設原本 Email 有 Description:

aria-describedby="email-description"

發生錯誤後,還需要加入 Error:

aria-describedby="email-description email-error"

也就是:

Input
 │
 └── aria-describedby
        │
        ├── email-description
        │
        └── email-error

畫面可能是:

Email
[ abc ]

系統通知將寄送至此信箱。
請輸入有效的 Email。

而不是只有視覺上把兩段文字放在 Input 下面。


9. 多個錯誤時,可以加入 Error Summary

當表單變長,只在每個欄位下面顯示錯誤,有時仍然不夠。

例如送出後:

⚠ 表單有 3 個欄位需要修正

• 姓名:請輸入姓名
• Email:請輸入有效的 Email
• 居住城市:請選擇城市

這就是 Error Summary。

它提供使用者一個快速掌握整張表單錯誤的位置。

概念上:

Submit
   ↓
Validate Form
   ↓
┌──────────────┐
│ 有錯誤?      │
└──────────────┘
   │
   ├─ No → Submit
   │
   └─ Yes
       ↓
    Error Summary
       ↓
    Field Errors

如果要再進一步改善 Keyboard Flow,也可以在驗證失敗後把 Focus 移到 Error Summary 或第一個錯誤欄位。

重點不是「畫面變紅」,而是讓使用者知道:

發生了什麼?
哪裡需要修正?
接下來應該去哪裡?


10. Keyboard Flow 要看整張 Form

前幾天測元件時,我們是一顆一顆測:

Checkbox → Space
Radio → Arrow Keys
Select → Enter / Arrow / Escape

今天則要換個角度。

從整張表單的第一個欄位開始按 Tab:

姓名
 ↓
Email
 ↓
Select
 ↓
Textarea
 ↓
Radio Group
 ↓
Switch
 ↓
Checkbox
 ↓
Submit

Focus 順序應該和畫面的閱讀順序一致。

也不需要為了「控制順序」到處加入:

tabindex="1"
tabindex="2"
tabindex="3"

正常的 DOM 順序通常就是最好的 Keyboard Flow。


11. React 和 Legacy 不需要用相同方法實作

CUI 一開始就不只打算服務 React。

React 可以使用 state:

hasError
 ↓
data-invalid
aria-invalid
FieldError

Legacy 則可以使用原生 JavaScript:

Validation Result
 ↓
DOM attributes
 ↓
data-invalid="true"
aria-invalid="true"
hidden=false

兩邊的實作方式不同,但遵守同一個 Field State Contract:

Invalid Field Contract

Field
└── data-invalid="true"

Control
├── aria-invalid="true"
└── aria-describedby="... error-id"

Error
├── data-cui-slot="field-error"
└── role="alert"

這也是前面持續加入 data-cui-* 的原因。

我們真正希望共享的不是 React implementation,而是:

Component Contract。


12. 今天的 Accessibility Checklist

完整 Form 做完後,可以從整體重新檢查:

  • [ ] 每個表單控制項都有可理解的 Label
  • [ ] Required 不只使用顏色或 * 表達
  • [ ] Description 使用 aria-describedby 建立關聯
  • [ ] Radio Group 使用 fieldset / legend
  • [ ] Checkbox 與 Switch 使用情境正確
  • [ ] Invalid Control 使用 aria-invalid
  • [ ] Error Message 與 Control 建立關聯
  • [ ] Error 不只靠紅色表示
  • [ ] 多個錯誤時可以提供 Error Summary
  • [ ] Keyboard Focus 順序符合閱讀順序
  • [ ] 所有互動元件都可以使用鍵盤完成操作

13. 從 Component 走到 Pattern

Day 11 到 Day 15,我們一直在增加 Form Components:

Input
Field
Textarea
Checkbox
Radio
Switch
Select

到了今天,它們第一次真正被組合起來:

                 Form Pattern
                      │
       ┌──────────────┼──────────────┐
       │              │              │
     Field         FieldSet       Validation
       │              │              │
       ↓              ↓              ↓
 Input / Select    Radio Group    FieldError
 Textarea          Checkbox       Error Summary
 Switch

這也是今天最重要的一件事:

UI Kit 不只是元件集合。

如果只有:

Button
Input
Select
Checkbox

我們只是擁有很多零件。

當我們開始定義:

元件如何組合
狀態如何傳遞
錯誤如何呈現
Focus 如何移動
不同技術如何遵守同一套 Contract

才開始真正形成一套可以拿來做系統的 UI Kit。


Day 17 預告

表單系列先告一段落。

明天開始處理另一類很常出現在系統介面的元件:

Badge、Alert、Empty State:狀態不能只靠顏色表達

成功、警告、錯誤、處理中……

如果全部都只做成:

綠色 = 成功
黃色 = 警告
紅色 = 錯誤

對部分使用者來說,這些狀態可能根本沒有差別。

Day 17 就來建立 CUI 的 Status / Feedback Pattern。


上一篇
Day 15: Select:不只是把選項藏進下拉選單
下一篇
Day 17: Badge、Alert、Empty State:狀態不能只靠顏色表達
系列文
30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言