昨天 Day 23,我們補上了幾個小型輔助元件:
其中 Tooltip 讓我們第一次比較完整地碰到 Hover、Focus、Escape 等互動問題。
今天要繼續往下走。
這次要做的是系統裡非常常見的元件:
Dialog。
例如使用者按下「編輯資料」:
申請紀錄
王小明 學分抵免 [編輯]
↓
┌──────────────────┐
│ 編輯申請資料 │
│ │
│ 姓名 │
│ [王小明 ] │
│ │
│ [取消] [儲存] │
└──────────────────┘
視覺上,Dialog 好像就是:
Button
↓
Overlay
↓
Content
但如果開始考慮 Accessibility,事情就沒這麼簡單了。
例如:
Dialog 開啟後,Focus 要去哪裡?
使用者按 Tab,能不能跑到背景?
按 Escape 之後會發生什麼事?
Dialog 關閉後,Focus 應該回哪裡?
Screen Reader 怎麼知道這是一個 Dialog?
如果 Dialog 裡有表單,驗證錯誤怎麼處理?
所以今天的重點不只是讓視窗彈出來。
而是:
Dialog 開啟後,如何讓使用者在正確的範圍內操作,並且能順利離開?
今天會建立 CUI 的 Dialog Component,並處理:
Dialog
│
├── Trigger
├── Portal
├── Backdrop
├── Content
│ ├── Header
│ ├── Title
│ ├── Description
│ ├── Body
│ └── Footer
└── Close
Accessibility 重點則是:
Accessible Name
Initial Focus
Modal Focus Containment
Keyboard Navigation
Escape
Focus Return
Background Inertness
最後再加入:
data-cui-slot
讓 Dialog 延續昨天整理的 CUI Component Contract。
昨天的 Tooltip 是補充資訊。
Tooltip
→ 顯示說明
→ 不應接管 Focus
→ 不應包含互動控制項
但 Dialog 不一樣。
Dialog 裡面可能有:
Input
Select
Checkbox
Button
甚至是一整張表單。
所以 Dialog 需要讓使用者真的進入一個新的操作情境。
這也是今天最大的差別:
Tooltip
→ Focus 留在 Trigger
Modal Dialog
→ Focus 進入 Dialog
→ 操作範圍暫時限制在 Dialog
→ 關閉後回到合理位置
也就是我們今天要處理的:
Focus Management。
這兩個詞常常被混在一起使用。
但其實:
Dialog
是一種具有獨立內容與互動情境的視窗。
Modal Dialog
則進一步要求:
Dialog 開啟時,使用者不能與背景內容互動。
例如:
┌───────────────────────────────────┐
│ 原本的頁面 │
│ │
│ ┌─────────────────┐ │
│ │ Modal Dialog │ │
│ │ │ │
│ │ [取消] [確定] │ │
│ └─────────────────┘ │
│ │
│ 背景暫時不可互動 │
└───────────────────────────────────┘
對 Modal Dialog 來說,背景不只是變暗而已。
還需要處理:
Pointer Interaction
Keyboard Focus
Assistive Technology
因此:
background: rgba(0, 0, 0, 0.5);
並不代表 Modal Accessibility 就完成了。
aria-modal="true" 不是魔法Modal Dialog 通常會使用:
role="dialog"
aria-modal="true"
其中:
role="dialog"
→ 告訴 Assistive Technology:
這是一個 Dialog
aria-modal="true"
→ 告訴 Assistive Technology:
這是 Modal
但要注意:
aria-modal="true" 不會自動阻止背景被操作。
它只是在描述目前的介面狀態。
真正的背景互動限制、Focus Management,仍然需要元件實作。
所以我們不能只寫:
<div role="dialog" aria-modal="true">
就認為 Modal 已經完成。
我們當然可以從:
const [open, setOpen] = useState(false)
開始。
然後:
{open && (
<div className="dialog">
...
</div>
)}
畫面應該很快就能完成。
但接著就需要自己處理:
Initial Focus
Tab / Shift + Tab
Escape
Focus Return
Background Inertness
Scroll Lock
Portal
Nested Dialog
這些事情加起來,比畫出 Dialog 本身還麻煩。
而且很容易只測到:
Mouse Click ✓
卻漏掉:
Keyboard
Screen Reader
Focus Restoration
因此 CUI 仍然延續前面的策略:
讓 Base UI 處理底層互動,CUI 負責樣式、語意規範與公開 Contract。
目前專案使用:
React
TypeScript
Tailwind CSS
shadcn/ui
Base UI
所以先執行:
npx shadcn@latest add dialog
建立:
src/components/ui/dialog.tsx
依照目前的 shadcn / Base UI 版本,產生的結構可能略有差異。
我們先不急著重寫它。
先理解:
Dialog
├── DialogTrigger
├── DialogPortal
├── DialogOverlay / Backdrop
├── DialogContent
├── DialogHeader
├── DialogTitle
├── DialogDescription
├── DialogFooter
└── DialogClose
每個部分分別負責什麼。
Dialog Root 負責整體開啟與關閉狀態。
Trigger 則是開啟 Dialog 的控制項。
例如:
<Dialog>
<DialogTrigger
render={<Button variant="outline" />}
>
編輯資料
</DialogTrigger>
<DialogContent>
...
</DialogContent>
</Dialog>
這裡使用 Base UI 的 render 組合方式,讓 Trigger 和 Button 共用同一個實際 DOM 元素。
而不是產生:
<button>
<button>編輯資料</button>
</button>
因為巢狀 Button 是不合法的互動結構。
這也是為什麼使用 Primitive 時,需要先理解它的 Composition API。
假設 Dialog 打開後,裡面只有:
姓名
[輸入框]
[取消] [儲存]
Screen Reader 可能知道這是一個 Dialog。
但它不一定知道:
這個 Dialog 是做什麼的?
所以 Dialog 需要 Accessible Name。
最常見的做法就是:
<DialogTitle>
編輯申請資料
</DialogTitle>
由 Primitive 建立正確的關聯。
如果直接使用 HTML,概念會類似:
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
>
<h2 id="dialog-title">
編輯申請資料
</h2>
</div>
這樣使用者進入 Dialog 時,就能知道目前操作情境。
除了 Title,也可以有:
<DialogDescription>
修改申請資料後,請按儲存完成更新。
</DialogDescription>
概念上:
DialogTitle
→ 這個 Dialog 是什麼?
DialogDescription
→ 這個 Dialog 要做什麼?
但不是所有 Dialog 都需要一大段 Description。
如果內容本身已經非常清楚,就不必為了湊齊元件而硬寫說明。
Accessibility 不是 ARIA 或文字越多越好。
而是讓資訊清楚、適量。
我們可以先建立一個簡單的編輯視窗。
<Dialog>
<DialogTrigger
render={<Button variant="outline" />}
>
編輯資料
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
編輯申請資料
</DialogTitle>
<DialogDescription>
修改完成後,請按儲存。
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Field>
<FieldLabel htmlFor="edit-name">
姓名
</FieldLabel>
<Input
id="edit-name"
defaultValue="王小明"
/>
</Field>
</div>
<DialogFooter>
<DialogClose
render={<Button variant="outline" />}
>
取消
</DialogClose>
<Button type="button">
儲存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
這裡的 DialogClose 使用方式需與實際產生的 Base UI API 一致。
另外,這只是元件展示。
真正的儲存流程仍然需要:
Validation
Submit
Success / Error
Close
不能按了「儲存」就直接假裝成功。
這是今天最重要的問題。
假設使用者按:
[編輯資料]
Dialog 開啟:
編輯申請資料
姓名
[王小明]
[取消] [儲存]
Focus 要去哪?
答案不是:
永遠放在第一個 Input。
而是:
根據 Dialog 的內容與任務,選擇合理的 Initial Focus。
例如簡單的編輯表單,Focus 放在第一個輸入欄位可能合理。
但如果 Dialog 有很長的說明內容,直接 Focus 第一個操作元件,可能讓使用者錯過前面的資訊。
這時可以考慮讓 Focus 先落在具有適當 tabIndex="-1" 的靜態標題或內容區域。
因此:
Initial Focus
≠
永遠第一個 Input
想像 Keyboard User 按下:
編輯資料
視覺上 Dialog 已經開啟。
但 Focus 還留在背景的「編輯資料」按鈕。
接著按 Tab:
下一個背景按鈕
這就很奇怪。
使用者看到的是 Dialog,鍵盤卻還在操作背景。
因此 Modal Dialog 開啟後,Focus 應該移入 Dialog。
這是 Modal Interaction 的基本要求。
當 Modal Dialog 開啟:
姓名 Input
取消 Button
儲存 Button
Keyboard Navigation 應該能在 Dialog 內移動。
例如:
姓名
↓ Tab
取消
↓ Tab
儲存
↓ Tab
回到 Dialog 內第一個可聚焦元素
反方向:
Shift + Tab
也應該維持在 Modal 內。
這就是常說的:
Focus Trap / Focus Containment。
但要注意,它不是要讓使用者永遠被困住。
因為使用者仍然可以透過:
取消
關閉
Escape(適用時)
離開 Dialog。
對一般 Modal Dialog 而言:
Escape
→ 關閉 Dialog
是常見且符合使用者預期的行為。
例如:
編輯申請資料
↓
按 Escape
↓
回到申請紀錄
但如果是正在執行不可中斷的操作,或關閉可能造成資料遺失,就需要額外考慮。
不能只因為:
Escape = Close
就忽略產品流程。
不過對今天這種簡單編輯 Dialog,先使用 Primitive 的預設關閉行為即可。
假設:
[編輯資料] ← Trigger
↓
Dialog Open
↓
[取消]
↓
Dialog Close
Focus 通常應該回到:
[編輯資料]
這叫:
Focus Return / Focus Restoration。
因為使用者原本就是從這裡進入 Dialog。
如果關閉後 Focus 突然回到頁面最上方,使用者可能需要重新找回操作位置。
假設 Dialog 是:
刪除申請紀錄
使用者按下:
確認刪除
資料真的被刪除了。
原本那一列的:
刪除 Button
也跟著消失。
這時就不可能回到原本的 Trigger。
因此需要根據操作結果決定:
下一筆資料
資料列表標題
成功訊息
其他合理操作位置
所以 Focus Return 的真正原則是:
回到合理的工作流程位置,而不是盲目回到原本 DOM。
當然可以。
這也是最常見的用途之一。
我們 Day 16 已經做過 Form Pattern:
Field
├── Label
├── Input
├── Description
└── Error
現在可以直接組進 Dialog。
例如:
Dialog
└── Form
├── Field
├── Field
└── Actions
這正好能驗證:
前面做的元件是不是真的可以組合?
而不是每次碰到新需求,就重新寫一套 Input 和 Validation。
假設使用者按下:
儲存
但姓名沒有填寫。
這時應該:
Dialog 保持開啟
↓
顯示錯誤
↓
引導使用者修正
而不是:
Validation Failed
↓
Dialog Close
例如:
<Field data-invalid={hasError}>
<FieldLabel htmlFor="edit-name">
姓名
</FieldLabel>
<Input
id="edit-name"
aria-invalid={hasError}
aria-describedby={
hasError ? "edit-name-error" : undefined
}
/>
{hasError && (
<FieldError id="edit-name-error">
請輸入姓名。
</FieldError>
)}
</Field>
提交失敗時,可以把 Focus 移到第一個無效欄位,並保留 Dialog。
這部分直接沿用 Day 16 的驗證策略。
接著是一個很容易搞混的地方。
例如:
編輯資料
適合一般 Dialog。
但:
確定要永久刪除這筆資料嗎?
就可能更適合:
Alert Dialog。
兩者不是單純顏色不同。
一般 Dialog:
編輯
設定
新增
查看詳細資訊
Alert Dialog:
重要確認
可能造成不可逆後果的操作
需要使用者明確決定的情境
而 Alert Dialog 會有不同的注意力與焦點設計需求。
假設:
確定要永久刪除這筆申請嗎?
[取消] [永久刪除]
如果 Dialog 一打開,就把 Focus 放在:
永久刪除
使用者可能因為連續按 Enter,直接觸發破壞性操作。
因此對不可逆的確認流程,通常更適合讓 Initial Focus 放在:
取消
也就是較安全的選項。
這也是為什麼:
Initial Focus
不能寫死成:
第一個 Button
或:
主要操作 Button
有些 Dialog 右上角會有:
×
視覺上大家知道是關閉。
但 Screen Reader 不能只得到:
X
或:
Close Icon
應該有:
關閉
例如:
<Button
variant="ghost"
size="icon-md"
aria-label="關閉對話框"
>
<XIcon aria-hidden="true" />
</Button>
如果 shadcn 產生的 DialogContent 已經內建關閉按鈕,也要檢查它是否具有正確的 Accessible Name。
不要又額外放一顆,結果出現兩個關閉按鈕。
很多 Dialog 允許:
點背景
↓
關閉 Dialog
但不是所有情境都適合。
例如:
查看簡單資訊
可能沒問題。
但:
填寫一半的長表單
如果使用者不小心點到背景,就把整張表單關掉,可能造成資料遺失。
所以:
Outside Click
也是需要依產品情境決定的行為。
CUI 可以提供底層能力,但不應該替所有系統硬性決定。
跟前面的元件一樣,我們保留 shadcn 產生的:
data-slot
再補上:
data-cui-slot
例如:
dialog-trigger
dialog-overlay
dialog-content
dialog-header
dialog-title
dialog-description
dialog-footer
dialog-close
不過這裡有個要注意的地方。
不是每個 React Component 都一定會輸出 DOM。
例如:
Dialog Root
Portal
可能只是負責狀態與渲染位置。
所以不需要硬塞:
data-cui-slot="dialog"
應該把 Contract 放在真正需要公開樣式 Hook 的 DOM 元素上。
data-state 和 data-cui-*Base UI 可能會輸出開啟與關閉相關的 State Attribute。
例如:
data-open
data-closed
或其他由實際 Primitive 定義的狀態。
CUI 不需要自己再維護:
data-cui-open="true"
因為這很容易產生:
Primitive State
≠
CUI State
的同步問題。
Day 22 已經定下原則:
已有可靠的 Native、ARIA 或 Primitive State,就優先使用,不要為了命名一致而複製一份。
例如:
Dialog Background
→ surface
Dialog Text
→ on-surface
Border
→ outline-variant
Focus
→ focus
不應該直接寫:
background: white;
color: #111111;
因為 CUI 已經有 Semantic Design Tokens。
未來如果要支援:
Dark Mode
Theme
Brand Customization
就不需要重新修改每顆 Dialog。
Dialog 可能有不同用途:
小型確認視窗
編輯表單
較長的設定內容
但目前不一定要馬上做:
<DialogContent size="sm" />
<DialogContent size="md" />
<DialogContent size="lg" />
可以先保留合理的預設寬度。
等真的出現多種尺寸需求,再決定是否要建立正式的 Size API。
這也延續 Day 22 的原則:
不要為了 API 完整,而提前增加不需要的 Variants。
桌面上:
┌──────────────┐
│ Dialog │
└──────────────┘
可能很自然。
但手機上:
Viewport 很窄
就需要注意:
Dialog Width
Max Height
Scrolling
Keyboard Overlay
Safe Area
尤其 Dialog 裡有很多 Field 時,不能讓:
儲存 Button
被擠到畫面外,卻完全無法捲動。
所以 Responsive Dialog 不只是:
width: 90%;
還需要確認內容真的可以操作。
這也是 CUI 架構開始出現挑戰的地方。
React:
Dialog Component
↓
Base UI
↓
Focus Management
但 Legacy:
<button>開啟</button>
<div data-cui-slot="dialog-content">
...
</div>
只有 HTML 和 CSS,並不會自動得到完整的 Modal Behavior。
因為它還需要:
Open / Close
Initial Focus
Focus Containment
Focus Return
Background Inertness
Escape
所以未來的 CDN:
cui.css
可以處理:
Visual Styling
但真正要在 Legacy 使用互動式 Dialog,仍然需要:
cui.js
或其他符合 CUI Contract 的互動實作。
這裡值得特別說明。
我們一開始的目標是:
Single Source, Multiple Outputs。
但這不代表:
React 和 Legacy
一定使用同一份 JavaScript Runtime
因為 React 依賴:
React
Base UI
Legacy 則不一定有這些環境。
所以比較合理的共享範圍是:
Design Tokens
Component Contract
Styling Rules
Accessibility Requirements
而不同環境的互動實作,可以由各自的 Adapter 處理。
例如:
React
→ Base UI Dialog
Legacy
→ Native Dialog / Vanilla JS Adapter
我們不應該為了宣稱「Single Source」,就假裝 React 的 Dialog JavaScript 可以直接在 Legacy 使用。
<dialog> 嗎?其實可以考慮。
HTML 本身就有:
<dialog>
搭配:
dialog.showModal()
可以使用瀏覽器原生的 Modal 行為。
例如:
<button id="open-dialog">
編輯資料
</button>
<dialog id="edit-dialog">
<h2>編輯申請資料</h2>
<p>請確認資料內容。</p>
<button id="close-dialog">
關閉
</button>
</dialog>
JavaScript:
const dialog = document.querySelector("#edit-dialog");
document
.querySelector("#open-dialog")
.addEventListener("click", () => {
dialog.showModal();
});
document
.querySelector("#close-dialog")
.addEventListener("click", () => {
dialog.close();
});
原生 <dialog> 搭配 showModal() 已經能提供許多 Modal 基礎能力,例如將對話框放入 Top Layer、讓背景變成 Inert,以及原生的鍵盤互動。
不過仍然要檢查:
Accessible Name
Initial Focus
Focus Return
Validation
Close Behavior
所以原生 <dialog> 是很好的 Legacy 候選方案,但不是「寫了就完全無障礙」。
完成 React Dialog 後,今天最重要的測試不是滑鼠。
而是:
Tab
Shift + Tab
Enter
Space
Escape
實際測試:
尤其最後兩點很容易被忽略。
接著想像使用 Screen Reader 時,Dialog 開啟後應該能理解:
編輯申請資料
對話框
修改完成後,請按儲存。
姓名
編輯文字
取消
按鈕
儲存
按鈕
實際朗讀順序會依 Screen Reader、瀏覽器與 Initial Focus 而不同。
我們要確認的是:
Dialog 有名稱
內容與控制項可理解
Focus 進入正確範圍
不會誤操作背景
關閉後能繼續原本工作
而不是要求每個 Screen Reader 都唸出完全一模一樣的句子。
今天最後整理一下:
Semantic
Focus
Keyboard
Interaction
完成後執行:
npm run build
npm run lint
npm run check:contrast
再檢查:
git status
git diff
今天的 Branch 可以使用:
git switch -c feat/dialog
Commit:
git commit -m "feat: add accessible dialog component"
當然,前提是今天的實作與測試都已完成。
今天加入的 Dialog,看起來只是多了一個彈出視窗。
但實際上,我們開始正式處理:
Focus Entry
Focus Containment
Focus Restoration
Background Inertness
Keyboard Interaction
Accessible Name
這些都是建立 Accessible Interactive Component 時非常重要的基礎。
而且今天也再次確認了 CUI 的架構:
CUI
│
Component Contract
│
┌──────────┴──────────┐
↓ ↓
React Legacy
│ │
Base UI Native Dialog
│ / JS Adapter
↓ ↓
Focus Management Focus Management
我們希望共享的是一致的規則,而不是強迫所有環境使用相同的 Framework。
今天最值得記住的是:
Dialog 的 Accessibility,不只是打開時能不能操作,更重要的是使用者如何進入、停留,以及離開。
一個 Dialog 即使畫面再漂亮,如果使用者按 Tab 會跑到背景、關閉後找不到原本的位置,它仍然不是一個好的 Dialog。
到目前為止,CUI 已經累積不少元件。
但我們一直有一個暫時的展示場:
App.tsx
Button 放在這裡。
Input 放在這裡。
Table、Pagination、Tooltip、Dialog 也都放在這裡。
元件越多,App.tsx 就越像一個大型展示倉庫。
所以明天,我們要正式引入:
Storybook。
把原本零散的元件展示整理成:
Storybook
│
├── Button
│ ├── Primary
│ ├── Secondary
│ └── Disabled
│
├── Input
│ ├── Default
│ └── Invalid
│
├── Table
│ └── Application Records
│
├── Tooltip
│ └── Icon Button
│
└── Dialog
└── Edit Form
讓 CUI 不只是有一堆可以使用的元件。
而是開始有一個:
讓開發者可以探索、操作、理解元件的正式文件環境。
Day 25:
Storybook:讓 UI Kit 不再只活在 App.tsx 裡。