Design System 101 - Modal
- 文章發表於
什麼是 Modal?
Modal 與大多數組件一樣,為用來顯示內容的組件。它可以透過使用者操作或是背景程序的觸發
何時使用
- 當需要暫時離開主流程以完成特定操作時。例如:列表篩選視窗等等。
- 攔截使用者當前的操作並顯示警示訊息。例如:登入牆。
- 當某些資訊不夠重要以至於不需要常駐在主頁面上時。例如:產品樣式資訊等等。
何時不使用
- 當只需要簡單的提示時。
- 若僅為短暫資訊顯示,使用 Toast。
- 若需要使用者確認資訊,使用 Dialog。
- 當只需要少量資訊的展示,避免中斷用戶操作,使用 Popover。
Anatomy
組件架構
組件 | 描述 |
---|---|
Modal.Root | 根組件,讓子組件之間的事件與狀態共享 |
Modal.Trigger | 用來觸發 Modal 開啟的組件 |
Modal.Portal | 用來包裹 Modal 的組件,主要是負責將 Modal 螢幕正確的位置 |
Modal.Content | Modal 的內容 |
Modal.Overlay | 主要將背景進行遮罩,通常會使頁面內容變暗。 |
使用方式
<Modal.Root><Modal.Trigger /><Modal.Portal><Modal.Overlay /><Modal.Content /></Modal.Portal></Modal.Root>
組件資料模型
Modal 本身沒有太多狀態需要進行管理,本篇將使用 ControlledState 進行開啟與關閉的狀態管理。
組件 API
General Props
屬性名稱 | 型別 | 描述 |
---|---|---|
children | React.ReactNode | 組件的子組件 |
className | string | 自定義 class |
as | React.ElementType | 自定義 HTML tag |
`Modal`
屬性名稱 | 型別 | 描述 |
---|---|---|
defaultOpen | string | 預設 Modal 是否開啟 |
open | string | 控制 Modal 是否開啟 |
onOpenChange | (value: string) => void | 當 Modal 狀態改變時觸發 |
`Modal.Overlay`
屬性名稱 | 型別 | 描述 |
---|---|---|
container | HTMLElement | 指定掛載的 DOM 元素,預設為 document.body |
`Modal.Trigger`
屬性名稱 (data-attribute) | 型別 | 描述 |
---|---|---|
[data-state] | string | open / closed |
`Modal.Overlay`
Data Attribute
屬性名稱 (data-attribute) | 型別 | 描述 |
---|---|---|
[data-state] | string | open / closed |
Modal 實作
實作重點
本次實作會以三種方式實現 Modal 組件,並分別探討其優缺點。
- 背景鎖 (Background Lock): 當 Modal 顯示時,背景要是可以選擇不能滾動。
- 聚焦鎖 (Focus Lock): 當 Modal 顯示時,聚焦要在 Modal 內,並且不可以離開 Modal,而當 Modal 關閉時,聚焦要先前的聚焦位置。
- 動畫 (Animation): Modal 顯示與關閉時,要有動畫效果。
- 遮罩 (Backdrop): 當 Modal 顯示時,背景要有遮罩效果。
React.createPortal
實作
1. 使用 使用 React API React.createPortal
來實作 Modal,我們就必需處理可訪問性的問題,像是聚焦與鍵盤交互。但同時我們可以擁有更多彈性去客製化 Modal 的行為與樣式。
實作會用到以下幾個先前章節有提過的概念:
- ControlledState:用來管理 Modal 的開啟與關閉狀態。
- FocusScope: 專門用來處理聚焦以及鍵盤交互的問題。
import Modal from './modal' import './styles.css' export default () => { return ( <div className="app"> <Modal> <Modal.Trigger>Open Modal</Modal.Trigger> <Modal.Portal> <Modal.Overlay className="modal-overlay" /> <Modal.Content className="modal-contents-container"> {(context) => { return ( <> <div className="modal-contents"> Hi I'm Modal!{' '} <div> <button onClick={context.onOpenToggle}>Close</button> </div> </div> </> ) }} </Modal.Content> </Modal.Portal> </Modal> </div> ) }
優點
- DOM 層次自由性:可以將子組件渲染到任何 DOM 節點,不受父組件 DOM 結構的限制。
- 客製化:可以更客製化 Modal 的行為與樣式。
- 模組化:Modal 是屬於 Overlay 的一種,可以將 Overlay 進行模組化,並且可以重複到其他組件上,像是 Tooltip, Modal Sheet, Alert 等等的。
缺點
- 需要開發者處理可訪問性:比如聚焦管理和鍵盤導航。
<dialog>
進行實作
2. 使用原生 dialog 是 HTML5 新增的元素,顧名思義就是顯示彈窗的 HTML 元素,它解決了幾個問題:
import { useState, useRef } from 'react' import { Modal } from './modal' export default () => { const dialogRef = useRef(null) return ( <div className="app"> <button onClick={() => { dialogRef.current?.showModal() }} > Open Modal </button> <Modal ref={dialogRef} onClose={() => { dialogRef.current?.close() }} > I'm a modal! </Modal> </div> ) }
優點
- 可訪問性:
<dialog>
元素會自動處理可訪問性問題,包含聚焦管理和鍵盤導航。 - 簡單:不需要額外的 DOM 結構,只需要一個
<dialog>
元素就可以了。 - 原生支援:因為是 HTML 標準的一部分,其會有更好的性能和較少的兼容性問題。
缺點
- 樣式較難客製化:相比於 React 組件,
<dialog>
的樣式和行為定制選項較少。 - 瀏覽器支援性:雖然現代瀏覽器大多支持
<dialog>
,但在一些舊瀏覽器中可能需要 polyfills。
Accessibility
以下皆是參考 WAI-ARIA 的 Modal Pattern 的規範。
鍵盤交互
鍵盤事件 | 描述 | 組件支援 |
---|---|---|
Tab | 移動聚焦到下一個可聚焦的元素,若聚焦在最後一個元素,則移動到第一個元素 | <FocusScope /> |
Shift + Tab | 移動聚焦到上一個可聚焦的元素,若聚焦在第一個元素,則移動到最後一個元素 | <FocusScope /> |
Escape | 關閉 Modal | <Modal /> |
ARIA 屬性
屬性名稱 | 型別 | 描述 | 組件 |
---|---|---|---|
role | string | dialog ,讓輔助技術知道這是對話框,可以使用 aria-labelledby 。 | <Modal /> |
aria-hidden | boolean | true ,讓輔助技術知道 Modal 關閉時,不需要將 Modal 內容讀出來。 | <Modal /> |
aria-modal | boolean | true ,防止輔助技術讓使用者感知到對話框以外的內容 。 | <Modal /> |
如果您喜歡這篇文章,請點擊下方按鈕分享給更多人,這將是對筆者創作的最大支持和鼓勵。