iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
自我挑戰組

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

Day 15: Select:不只是把選項藏進下拉選單

  • 分享至 

  • xImage
  •  

前幾天我們完成了 Input、Textarea、Checkbox、Radio Group 與 Switch,今天繼續擴充表單元件,來做一個互動稍微複雜一些的元件:Select。

Select 看起來只是「點一下 → 跳出選項 → 選一個」,但實際上需要處理的事情不少:

  • Trigger 如何開啟選單
  • 目前選中的值如何呈現
  • 鍵盤如何操作
  • Focus 如何移動
  • Disabled 狀態
  • 選項的 Selected 狀態
  • 如何與 Label / Description / Error 建立關係

這也是為什麼這類元件比原生 Input 更適合建立在成熟的 Primitive 上。


1. Select 的基本結構

一個 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>

畫面上看起來可能只是:

城市

┌─────────────────────────┐
│ 請選擇城市            ▼ │
└─────────────────────────┘

開啟後:

┌─────────────────────────┐
│ 台北                    │
│ 桃園                  ✓ │
│ 台中                    │
└─────────────────────────┘

但每一層都有自己的責任。


2. 為什麼不自己用 div 做?

很容易寫出這種 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 與使用規範。


3. Select 也要進入 CUI Contract

前面我們已經逐漸建立:

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 建立樣式與行為。


4. Select 也應該屬於 Field

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 的價值。


5. Placeholder 不等於 Label

Select 很常出現:

[ 請選擇城市 ▼ ]

但「請選擇城市」只是 Placeholder。

它不能取代:

城市
[ 請選擇城市 ▼ ]

因此仍然需要:

<FieldLabel htmlFor="city">
  城市
</FieldLabel>

再搭配:

<SelectValue placeholder="請選擇城市" />

兩者責任不同:

Label
→ 這個欄位是什麼?

Placeholder
→ 尚未選擇時要顯示什麼?

這個原則其實和 Input 完全相同。


6. Keyboard Interaction

Select 最重要的地方之一,就是不能只支援滑鼠。

實作完成後,需要確認基本鍵盤操作:

Tab
→ Focus Select Trigger

Enter / Space
→ 開啟選單

↑ / ↓
→ 在選項之間移動

Enter
→ 選擇目前選項

Escape
→ 關閉選單

例如:

城市

[ 請選擇城市 ▼ ]

        ↓ Enter

┌────────────────────┐
│ 台北               │
│ 桃園               │
│ 台中               │
└────────────────────┘

        ↓ Arrow Down

┌────────────────────┐
│ 台北               │
│ 桃園 ← Focus       │
│ 台中               │
└────────────────────┘

選擇後:

[ 桃園 ▼ ]

這些行為不應該由 CUI 自己重新發明,而是交給底層 Primitive 處理。


7. Selected 不應該只靠顏色

選中的 Item 可以有背景或文字顏色差異,但不能只靠顏色表達。

例如:

台北
桃園  ✓
台中

Check Icon 提供了額外的視覺線索:

Color
+
Check Icon
+
元件本身的 Selected State

這也再次用到了前面建立的 Icon 系統。


8. Invalid State

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。


9. Disabled State

Select 本身可以 Disabled:

<Select disabled>
  ...
</Select>

個別 Item 也可能 Disabled:

<SelectItem
  value="kaohsiung"
  disabled
>
  高雄(暫停使用)
</SelectItem>

兩者意義不同:

Select disabled
→ 整個欄位不能操作

SelectItem disabled
→ Select 可以使用,但某個選項不能選

實作時也要確認 Disabled 不只是「變灰」,而是真的不能操作。


10. Select 和原生 select 怎麼選?

原生 HTML 本來就有:

<select>
  <option>台北</option>
  <option>桃園</option>
</select>

而且原生 <select> 本身就具有很好的平台與 Accessibility 支援。

所以並不是看到下拉選單就一定要做 Custom Select。

可以簡單理解:

需求單純
+
原生樣式可以接受
        ↓
<select>


需要 Design System 統一外觀
+
複雜內容 / 狀態 / Popup UI
        ↓
Custom Select Primitive

CUI 提供 Custom Select,不代表原生 <select> 就是不好的選擇。

有時候最簡單的原生控制項,反而是最穩定的解法。


11. 今天的 Accessibility Checklist

今天完成後,我會確認:

  • [ ] Select 有可存取的 Label
  • [ ] Placeholder 沒有取代 Label
  • [ ] Trigger 可以透過 Tab Focus
  • [ ] 可以使用鍵盤開啟 Select
  • [ ] 可以使用方向鍵移動選項
  • [ ] 可以使用 Enter 選擇
  • [ ] 可以使用 Escape 關閉
  • [ ] Focus 狀態清楚
  • [ ] Selected Item 不只靠顏色辨識
  • [ ] Disabled Select 不可操作
  • [ ] Disabled Item 不可選擇
  • [ ] Invalid State 使用 aria-invalid
  • [ ] Error 使用 aria-describedby 建立關聯

12. 今天完成了什麼?

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 的開始。


Day 16 預告

明天繼續表單系列,來處理:

Select 的延伸與表單組合實戰

我們會開始把目前完成的:

Input
Textarea
Select
Checkbox
Radio Group
Switch

真正組成一個完整表單,看看當元件從單獨 Demo 走進實際介面後,Spacing、Error、Required、Description 與 Keyboard Flow 是否仍然合理。


上一篇
Day 14: Checkbox、Radio、Switch:都是選擇,但意思不一樣
下一篇
Day 16: 把表單元件組起來:第一個完整 Form Pattern
系列文
30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言