前幾天我們陸續完成了:
單獨看,每個元件都已經可以正常使用。
但真正的系統不會只有:
<Input />
或:
<Select />
而是會長得像:
姓名
[ ]
Email
[ ]
系統通知將寄送至此信箱。
居住城市
[ 請選擇城市 ▼ ]
個人簡介
[ ]
[ ]
偏好的聯絡方式
● Email
○ 電話
Email 通知 [ ON ]
☐ 我已閱讀並同意使用條款
[ 儲存設定 ]
所以今天先不新增元件。
來把前五天做的 Form Components,真正組成第一個 Form 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。
今天做的是一張「個人資料設定」表單:
<form>
<FieldGroup>
{/* fields */}
</FieldGroup>
</form>
這也是 FieldGroup 第一次真正開始負責整張表單的排列。
概念上:
Form
└── FieldGroup
├── Field
├── Field
├── Field
├── FieldSet
├── Field
└── Field
前面建立的 Field system 開始派上用場了。
姓名是必填欄位:
<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。
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 之間應該存在真正的語意關係。
昨天完成的 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 的價值。
Radio Group 又不太一樣。
例如:
偏好的聯絡方式
● Email
○ 電話
這裡除了每一個 Radio 的 Label:
Email
電話
還需要描述:
這整組 Radio 到底是在選什麼?
因此使用:
<FieldSet>
<FieldLegend>
偏好的聯絡方式
</FieldLegend>
<RadioGroup defaultValue="email">
...
</RadioGroup>
</FieldSet>
結構變成:
fieldset
│
├── legend
│ └── 偏好的聯絡方式
│
└── RadioGroup
├── Email
└── 電話
這比單純在 Radio 上面放一個 <div> 標題更具有表單語意。
完整表單裡也同時放入 Checkbox 與 Switch。
Switch:
Email 通知 [ ON ]
接收重要的帳號與系統通知。
代表:
這個功能現在是開還是關?
Checkbox:
☐ 我已閱讀並同意使用條款
代表:
我是否選擇/同意這件事情?
因此即使兩個元件都有:
true / false
也不能因為資料型別相同,就隨便互換。
元件選擇應該反映使用者正在做的事情,而不是只看程式裡存的是不是 Boolean。
真正開始做 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 決定。
假設原本 Email 有 Description:
aria-describedby="email-description"
發生錯誤後,還需要加入 Error:
aria-describedby="email-description email-error"
也就是:
Input
│
└── aria-describedby
│
├── email-description
│
└── email-error
畫面可能是:
Email
[ abc ]
系統通知將寄送至此信箱。
請輸入有效的 Email。
而不是只有視覺上把兩段文字放在 Input 下面。
當表單變長,只在每個欄位下面顯示錯誤,有時仍然不夠。
例如送出後:
⚠ 表單有 3 個欄位需要修正
• 姓名:請輸入姓名
• Email:請輸入有效的 Email
• 居住城市:請選擇城市
這就是 Error Summary。
它提供使用者一個快速掌握整張表單錯誤的位置。
概念上:
Submit
↓
Validate Form
↓
┌──────────────┐
│ 有錯誤? │
└──────────────┘
│
├─ No → Submit
│
└─ Yes
↓
Error Summary
↓
Field Errors
如果要再進一步改善 Keyboard Flow,也可以在驗證失敗後把 Focus 移到 Error Summary 或第一個錯誤欄位。
重點不是「畫面變紅」,而是讓使用者知道:
發生了什麼?
哪裡需要修正?
接下來應該去哪裡?
前幾天測元件時,我們是一顆一顆測:
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。
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。
完整 Form 做完後,可以從整體重新檢查:
* 表達aria-describedby 建立關聯fieldset / legend
aria-invalid
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。
表單系列先告一段落。
明天開始處理另一類很常出現在系統介面的元件:
Badge、Alert、Empty State:狀態不能只靠顏色表達
成功、警告、錯誤、處理中……
如果全部都只做成:
綠色 = 成功
黃色 = 警告
紅色 = 錯誤
對部分使用者來說,這些狀態可能根本沒有差別。
Day 17 就來建立 CUI 的 Status / Feedback Pattern。