iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
自我挑戰組

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

Day 24: Dialog:打開一個視窗之後,Focus 到底該去哪?

  • 分享至 

  • xImage
  •  

昨天 Day 23,我們補上了幾個小型輔助元件:

  • Tooltip
  • Separator
  • Visually Hidden

其中 Tooltip 讓我們第一次比較完整地碰到 Hover、Focus、Escape 等互動問題。

今天要繼續往下走。

這次要做的是系統裡非常常見的元件:

Dialog。

例如使用者按下「編輯資料」:

申請紀錄

王小明    學分抵免    [編輯]
                         ↓
             ┌──────────────────┐
             │ 編輯申請資料     │
             │                  │
             │ 姓名             │
             │ [王小明       ]  │
             │                  │
             │ [取消] [儲存]    │
             └──────────────────┘

視覺上,Dialog 好像就是:

Button
  ↓
Overlay
  ↓
Content

但如果開始考慮 Accessibility,事情就沒這麼簡單了。

例如:

Dialog 開啟後,Focus 要去哪裡?

使用者按 Tab,能不能跑到背景?

按 Escape 之後會發生什麼事?

Dialog 關閉後,Focus 應該回哪裡?

Screen Reader 怎麼知道這是一個 Dialog?

如果 Dialog 裡有表單,驗證錯誤怎麼處理?

所以今天的重點不只是讓視窗彈出來。

而是:

Dialog 開啟後,如何讓使用者在正確的範圍內操作,並且能順利離開?


1. 今天要完成什麼?

今天會建立 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。


2. Dialog 和 Tooltip 有什麼不同?

昨天的 Tooltip 是補充資訊。

Tooltip
→ 顯示說明
→ 不應接管 Focus
→ 不應包含互動控制項

但 Dialog 不一樣。

Dialog 裡面可能有:

Input
Select
Checkbox
Button

甚至是一整張表單。

所以 Dialog 需要讓使用者真的進入一個新的操作情境。

這也是今天最大的差別:

Tooltip
→ Focus 留在 Trigger

Modal Dialog
→ Focus 進入 Dialog
→ 操作範圍暫時限制在 Dialog
→ 關閉後回到合理位置

也就是我們今天要處理的:

Focus Management。


3. 先分清楚 Dialog 和 Modal Dialog

這兩個詞常常被混在一起使用。

但其實:

Dialog

是一種具有獨立內容與互動情境的視窗。

Modal Dialog

則進一步要求:

Dialog 開啟時,使用者不能與背景內容互動。

例如:

┌───────────────────────────────────┐
│ 原本的頁面                        │
│                                   │
│       ┌─────────────────┐         │
│       │ Modal Dialog    │         │
│       │                 │         │
│       │ [取消] [確定]   │         │
│       └─────────────────┘         │
│                                   │
│ 背景暫時不可互動                  │
└───────────────────────────────────┘

對 Modal Dialog 來說,背景不只是變暗而已。

還需要處理:

Pointer Interaction
Keyboard Focus
Assistive Technology

因此:

background: rgba(0, 0, 0, 0.5);

並不代表 Modal Accessibility 就完成了。


4. 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 已經完成。


5. 為什麼不自己寫一套 Dialog?

我們當然可以從:

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。


6. 使用 shadcn/ui 建立 Dialog

目前專案使用:

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

每個部分分別負責什麼。


7. Dialog Root 和 Trigger

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。


8. Dialog 一定要有 Accessible Name

假設 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 時,就能知道目前操作情境。


9. Description 是補充資訊

除了 Title,也可以有:

<DialogDescription>
  修改申請資料後,請按儲存完成更新。
</DialogDescription>

概念上:

DialogTitle
→ 這個 Dialog 是什麼?

DialogDescription
→ 這個 Dialog 要做什麼?

但不是所有 Dialog 都需要一大段 Description。

如果內容本身已經非常清楚,就不必為了湊齊元件而硬寫說明。

Accessibility 不是 ARIA 或文字越多越好。

而是讓資訊清楚、適量。


10. 第一個完整的 Dialog

我們可以先建立一個簡單的編輯視窗。

<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

不能按了「儲存」就直接假裝成功。


11. Dialog 開啟後,Focus 應該去哪?

這是今天最重要的問題。

假設使用者按:

[編輯資料]

Dialog 開啟:

編輯申請資料

姓名
[王小明]

[取消] [儲存]

Focus 要去哪?

答案不是:

永遠放在第一個 Input。

而是:

根據 Dialog 的內容與任務,選擇合理的 Initial Focus。

例如簡單的編輯表單,Focus 放在第一個輸入欄位可能合理。

但如果 Dialog 有很長的說明內容,直接 Focus 第一個操作元件,可能讓使用者錯過前面的資訊。

這時可以考慮讓 Focus 先落在具有適當 tabIndex="-1" 的靜態標題或內容區域。

因此:

Initial Focus
≠
永遠第一個 Input

12. 為什麼 Initial Focus 這麼重要?

想像 Keyboard User 按下:

編輯資料

視覺上 Dialog 已經開啟。

但 Focus 還留在背景的「編輯資料」按鈕。

接著按 Tab:

下一個背景按鈕

這就很奇怪。

使用者看到的是 Dialog,鍵盤卻還在操作背景。

因此 Modal Dialog 開啟後,Focus 應該移入 Dialog。

這是 Modal Interaction 的基本要求。


13. Tab 和 Shift + Tab

當 Modal Dialog 開啟:

姓名 Input
取消 Button
儲存 Button

Keyboard Navigation 應該能在 Dialog 內移動。

例如:

姓名
 ↓ Tab
取消
 ↓ Tab
儲存
 ↓ Tab
回到 Dialog 內第一個可聚焦元素

反方向:

Shift + Tab

也應該維持在 Modal 內。

這就是常說的:

Focus Trap / Focus Containment。

但要注意,它不是要讓使用者永遠被困住。

因為使用者仍然可以透過:

取消
關閉
Escape(適用時)

離開 Dialog。


14. Escape 應該做什麼?

對一般 Modal Dialog 而言:

Escape
→ 關閉 Dialog

是常見且符合使用者預期的行為。

例如:

編輯申請資料
↓
按 Escape
↓
回到申請紀錄

但如果是正在執行不可中斷的操作,或關閉可能造成資料遺失,就需要額外考慮。

不能只因為:

Escape = Close

就忽略產品流程。

不過對今天這種簡單編輯 Dialog,先使用 Primitive 的預設關閉行為即可。


15. 關閉之後,Focus 應該回哪?

假設:

[編輯資料] ← Trigger
     ↓
Dialog Open
     ↓
[取消]
     ↓
Dialog Close

Focus 通常應該回到:

[編輯資料]

這叫:

Focus Return / Focus Restoration。

因為使用者原本就是從這裡進入 Dialog。

如果關閉後 Focus 突然回到頁面最上方,使用者可能需要重新找回操作位置。


16. 但 Focus Return 也不是永遠回 Trigger

假設 Dialog 是:

刪除申請紀錄

使用者按下:

確認刪除

資料真的被刪除了。

原本那一列的:

刪除 Button

也跟著消失。

這時就不可能回到原本的 Trigger。

因此需要根據操作結果決定:

下一筆資料
資料列表標題
成功訊息
其他合理操作位置

所以 Focus Return 的真正原則是:

回到合理的工作流程位置,而不是盲目回到原本 DOM。


17. Dialog 裡面可以放 Form 嗎?

當然可以。

這也是最常見的用途之一。

我們 Day 16 已經做過 Form Pattern:

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

現在可以直接組進 Dialog。

例如:

Dialog
└── Form
    ├── Field
    ├── Field
    └── Actions

這正好能驗證:

前面做的元件是不是真的可以組合?

而不是每次碰到新需求,就重新寫一套 Input 和 Validation。


18. Dialog 裡的 Validation Error

假設使用者按下:

儲存

但姓名沒有填寫。

這時應該:

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 的驗證策略。


19. Dialog 和 Alert Dialog 有什麼不同?

接著是一個很容易搞混的地方。

例如:

編輯資料

適合一般 Dialog。

但:

確定要永久刪除這筆資料嗎?

就可能更適合:

Alert Dialog。

兩者不是單純顏色不同。

一般 Dialog:

編輯
設定
新增
查看詳細資訊

Alert Dialog:

重要確認
可能造成不可逆後果的操作
需要使用者明確決定的情境

而 Alert Dialog 會有不同的注意力與焦點設計需求。


20. 刪除確認為什麼不能直接 Focus「刪除」?

假設:

確定要永久刪除這筆申請嗎?

[取消] [永久刪除]

如果 Dialog 一打開,就把 Focus 放在:

永久刪除

使用者可能因為連續按 Enter,直接觸發破壞性操作。

因此對不可逆的確認流程,通常更適合讓 Initial Focus 放在:

取消

也就是較安全的選項。

這也是為什麼:

Initial Focus

不能寫死成:

第一個 Button

或:

主要操作 Button

21. Dialog 的關閉按鈕也需要 Accessible Name

有些 Dialog 右上角會有:

×

視覺上大家知道是關閉。

但 Screen Reader 不能只得到:

X

或:

Close Icon

應該有:

關閉

例如:

<Button
  variant="ghost"
  size="icon-md"
  aria-label="關閉對話框"
>
  <XIcon aria-hidden="true" />
</Button>

如果 shadcn 產生的 DialogContent 已經內建關閉按鈕,也要檢查它是否具有正確的 Accessible Name。

不要又額外放一顆,結果出現兩個關閉按鈕。


22. Overlay 點擊後要不要關閉?

很多 Dialog 允許:

點背景
↓
關閉 Dialog

但不是所有情境都適合。

例如:

查看簡單資訊

可能沒問題。

但:

填寫一半的長表單

如果使用者不小心點到背景,就把整張表單關掉,可能造成資料遺失。

所以:

Outside Click

也是需要依產品情境決定的行為。

CUI 可以提供底層能力,但不應該替所有系統硬性決定。


23. Dialog 的 CUI Contract

跟前面的元件一樣,我們保留 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 元素上。


24. 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,就優先使用,不要為了命名一致而複製一份。


25. Dialog 的樣式也要使用 Design Tokens

例如:

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。


26. Dialog 的尺寸

Dialog 可能有不同用途:

小型確認視窗
編輯表單
較長的設定內容

但目前不一定要馬上做:

<DialogContent size="sm" />
<DialogContent size="md" />
<DialogContent size="lg" />

可以先保留合理的預設寬度。

等真的出現多種尺寸需求,再決定是否要建立正式的 Size API。

這也延續 Day 22 的原則:

不要為了 API 完整,而提前增加不需要的 Variants。


27. Dialog 的 Responsive 問題

桌面上:

        ┌──────────────┐
        │ Dialog       │
        └──────────────┘

可能很自然。

但手機上:

Viewport 很窄

就需要注意:

Dialog Width
Max Height
Scrolling
Keyboard Overlay
Safe Area

尤其 Dialog 裡有很多 Field 時,不能讓:

儲存 Button

被擠到畫面外,卻完全無法捲動。

所以 Responsive Dialog 不只是:

width: 90%;

還需要確認內容真的可以操作。


28. Dialog 和 Legacy

這也是 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 的互動實作。


29. 這是不是違反 Single Source?

這裡值得特別說明。

我們一開始的目標是:

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 使用。


30. 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 候選方案,但不是「寫了就完全無障礙」。


31. 今天的 Keyboard Test

完成 React Dialog 後,今天最重要的測試不是滑鼠。

而是:

Tab
Shift + Tab
Enter
Space
Escape

實際測試:

  1. Tab 移到 Dialog Trigger。
  2. Enter 開啟 Dialog。
  3. 確認 Focus 進入 Dialog。
  4. Tab 依序操作內容。
  5. Shift + Tab 反方向移動。
  6. 確認 Focus 不會跑到 Modal 背景。
  7. Escape 關閉 Dialog。
  8. 確認 Focus 回到合理位置。

尤其最後兩點很容易被忽略。


32. Screen Reader Test

接著想像使用 Screen Reader 時,Dialog 開啟後應該能理解:

編輯申請資料
對話框

修改完成後,請按儲存。

姓名
編輯文字

取消
按鈕

儲存
按鈕

實際朗讀順序會依 Screen Reader、瀏覽器與 Initial Focus 而不同。

我們要確認的是:

Dialog 有名稱

內容與控制項可理解

Focus 進入正確範圍

不會誤操作背景

關閉後能繼續原本工作

而不是要求每個 Screen Reader 都唸出完全一模一樣的句子。


33. Accessibility Checklist

今天最後整理一下:

Semantic

  • [ ] Dialog 有正確的 Role
  • [ ] Modal 狀態正確
  • [ ] Dialog 有 Accessible Name
  • [ ] Description 有需要時才提供

Focus

  • [ ] 開啟後 Focus 進入 Dialog
  • [ ] Initial Focus 符合操作情境
  • [ ] Modal 開啟時 Focus 不會跑到背景
  • [ ] 關閉後 Focus 回到合理位置

Keyboard

  • [ ] Trigger 可用鍵盤開啟
  • [ ] Tab / Shift + Tab 正常
  • [ ] Escape 可依情境關閉
  • [ ] 關閉按鈕可操作

Interaction

  • [ ] 背景不能被誤操作
  • [ ] Outside Click 行為合理
  • [ ] Validation Error 不會意外關閉 Dialog
  • [ ] 手機與放大畫面下仍可操作

34. 最後執行檢查

完成後執行:

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"

當然,前提是今天的實作與測試都已完成。


Day 24 完成

今天加入的 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。


Next:Day 25

到目前為止,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 裡。


上一篇
Day 23: Tooltip 不只是 Hover:補齊 UI Kit 裡的小型輔助元件
系列文
30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言