前幾天我們完成了 Input、Textarea、Checkbox、Radio Group 與 Switch,今天繼續擴充表單元件,來做一個互動稍微複雜一些的元件:Select。
Select 看起來只是「點一下 → 跳出選項 → 選一個」,但實際上需要處理的事情不少:
這也是為什麼這類元件比原生 Input 更適合建立在成熟的 Primitive 上。
一個 Select 並不只有一顆按鈕和幾個選項。
概念上可以拆成:
Select
├── Trigger
│ └── Value
│
└── Content
├── Item
├── Item
└── Item
例如:
<Select>
<SelectTrigger>
<SelectValue placeholder="請選擇城市" />
</SelectTrigger>
<SelectContent>
<SelectItem value="taipei">台北</SelectItem>
<SelectItem value="taoyuan">桃園</SelectItem>
<SelectItem value="taichung">台中</SelectItem>
</SelectContent>
</Select>
畫面上看起來可能只是:
城市
┌─────────────────────────┐
│ 請選擇城市 ▼ │
└─────────────────────────┘
開啟後:
┌─────────────────────────┐
│ 台北 │
│ 桃園 ✓ │
│ 台中 │
└─────────────────────────┘
但每一層都有自己的責任。
很容易寫出這種 Select:
<div class="select">
請選擇城市
</div>
點擊後再顯示:
<div class="options">
<div>台北</div>
<div>桃園</div>
<div>台中</div>
</div>
滑鼠看起來可以使用,但問題馬上就來了:
Tab 能不能操作?
Enter / Space 能不能開啟?
方向鍵能不能移動?
Escape 能不能關閉?
Focus 開啟後應該去哪裡?
螢幕閱讀器知不知道目前選了什麼?
如果全部自己實作,很快就會變成一個小型互動系統。
因此 CUI 的策略仍然跟 Checkbox、Radio、Switch 一樣:
Primitive 負責複雜互動與 Accessibility,CUI 負責 Design System 的視覺、API 與使用規範。
前面我們已經逐漸建立:
data-cui-slot="button"
data-cui-slot="input"
data-cui-slot="textarea"
data-cui-slot="checkbox"
data-cui-slot="radio-group"
data-cui-slot="switch"
Select 也延續相同規則。
例如:
data-cui-slot="select-trigger"
data-cui-slot="select-value"
data-cui-slot="select-content"
data-cui-slot="select-item"
這些 Attribute 不只是現在 React 元件使用。
未來 CUI CDN / Legacy 版本也可以依照相同的 DOM Contract 建立樣式與行為。
Day 12 做的 Field 到現在已經開始一直被重用了。
Select 也不例外:
<Field>
<FieldLabel htmlFor="city">
城市
</FieldLabel>
<Select>
<SelectTrigger
id="city"
aria-describedby="city-description"
>
<SelectValue placeholder="請選擇城市" />
</SelectTrigger>
<SelectContent>
<SelectItem value="taipei">台北</SelectItem>
<SelectItem value="taoyuan">桃園</SelectItem>
<SelectItem value="taichung">台中</SelectItem>
</SelectContent>
</Select>
<FieldDescription id="city-description">
請選擇目前居住的城市。
</FieldDescription>
</Field>
因此 Form 的結構逐漸變得一致:
Field
├── FieldLabel
├── Control
│ ├── Input
│ ├── Textarea
│ ├── Select
│ ├── Checkbox
│ ├── Radio
│ └── Switch
├── FieldDescription
└── FieldError
這也是建立 Field abstraction 的價值。
Select 很常出現:
[ 請選擇城市 ▼ ]
但「請選擇城市」只是 Placeholder。
它不能取代:
城市
[ 請選擇城市 ▼ ]
因此仍然需要:
<FieldLabel htmlFor="city">
城市
</FieldLabel>
再搭配:
<SelectValue placeholder="請選擇城市" />
兩者責任不同:
Label
→ 這個欄位是什麼?
Placeholder
→ 尚未選擇時要顯示什麼?
這個原則其實和 Input 完全相同。
Select 最重要的地方之一,就是不能只支援滑鼠。
實作完成後,需要確認基本鍵盤操作:
Tab
→ Focus Select Trigger
Enter / Space
→ 開啟選單
↑ / ↓
→ 在選項之間移動
Enter
→ 選擇目前選項
Escape
→ 關閉選單
例如:
城市
[ 請選擇城市 ▼ ]
↓ Enter
┌────────────────────┐
│ 台北 │
│ 桃園 │
│ 台中 │
└────────────────────┘
↓ Arrow Down
┌────────────────────┐
│ 台北 │
│ 桃園 ← Focus │
│ 台中 │
└────────────────────┘
選擇後:
[ 桃園 ▼ ]
這些行為不應該由 CUI 自己重新發明,而是交給底層 Primitive 處理。
選中的 Item 可以有背景或文字顏色差異,但不能只靠顏色表達。
例如:
台北
桃園 ✓
台中
Check Icon 提供了額外的視覺線索:
Color
+
Check Icon
+
元件本身的 Selected State
這也再次用到了前面建立的 Icon 系統。
Select 同樣需要支援表單錯誤:
<Field data-invalid="true">
<FieldLabel htmlFor="city">
城市
</FieldLabel>
<Select>
<SelectTrigger
id="city"
aria-invalid="true"
aria-describedby="city-error"
>
<SelectValue placeholder="請選擇城市" />
</SelectTrigger>
<SelectContent>
<SelectItem value="taipei">台北</SelectItem>
<SelectItem value="taoyuan">桃園</SelectItem>
</SelectContent>
</Select>
<FieldError id="city-error">
請選擇城市。
</FieldError>
</Field>
這跟 Input、Textarea 的規則完全一致:
Control
│
├── aria-invalid="true"
│
└── aria-describedby
↓
FieldError
也代表我們不用為 Select 再建立一套新的 Error API。
Select 本身可以 Disabled:
<Select disabled>
...
</Select>
個別 Item 也可能 Disabled:
<SelectItem
value="kaohsiung"
disabled
>
高雄(暫停使用)
</SelectItem>
兩者意義不同:
Select disabled
→ 整個欄位不能操作
SelectItem disabled
→ Select 可以使用,但某個選項不能選
實作時也要確認 Disabled 不只是「變灰」,而是真的不能操作。
原生 HTML 本來就有:
<select>
<option>台北</option>
<option>桃園</option>
</select>
而且原生 <select> 本身就具有很好的平台與 Accessibility 支援。
所以並不是看到下拉選單就一定要做 Custom Select。
可以簡單理解:
需求單純
+
原生樣式可以接受
↓
<select>
需要 Design System 統一外觀
+
複雜內容 / 狀態 / Popup UI
↓
Custom Select Primitive
CUI 提供 Custom Select,不代表原生 <select> 就是不好的選擇。
有時候最簡單的原生控制項,反而是最穩定的解法。
今天完成後,我會確認:
aria-invalid
aria-describedby 建立關聯Day 15 我們開始進入比一般 Input 更複雜的互動元件:
Select
├── Trigger
├── Value
├── Content
└── Item
同時延續 CUI 前面建立的幾個原則:
Design Tokens
↓
一致的視覺語言
Primitive
↓
互動與 Accessibility
Field
↓
Label / Description / Error
data-cui-*
↓
CUI DOM Contract
做到現在會發現,前面花時間建立的 Field、Icon、Token 並不是獨立的小練習。
元件越來越多之後,它們開始互相組合:
CUI Form
Field ────────────────┐
│
Icon ───────┐ │
↓ ↓
Select ← FieldLabel
│ FieldDescription
│ FieldError
↓
Design Tokens
這才是 UI Kit 慢慢變成一套 Design System 的開始。
明天繼續表單系列,來處理:
Select 的延伸與表單組合實戰
我們會開始把目前完成的:
Input
Textarea
Select
Checkbox
Radio Group
Switch
真正組成一個完整表單,看看當元件從單獨 Demo 走進實際介面後,Spacing、Error、Required、Description 與 Keyboard Flow 是否仍然合理。