Design System 101 - Modal

什麼是 Modal?

Modal 與大多數組件一樣,為用來顯示內容的組件。它可以透過使用者操作或是背景程序的觸發

何時使用

  • 當需要暫時離開主流程以完成特定操作時。例如:列表篩選視窗等等。
  • 攔截使用者當前的操作並顯示警示訊息。例如:登入牆
  • 當某些資訊不夠重要以至於不需要常駐在主頁面上時。例如:產品樣式資訊等等。

何時不使用

  • 當只需要簡單的提示時。
    • 若僅為短暫資訊顯示,使用 Toast。
    • 若需要使用者確認資訊,使用 Dialog。
  • 當只需要少量資訊的展示,避免中斷用戶操作,使用 Popover。

Anatomy

組件架構

組件描述
Modal.Root根組件,讓子組件之間的事件與狀態共享
Modal.Trigger用來觸發 Modal 開啟的組件
Modal.Portal用來包裹 Modal 的組件,主要是負責將 Modal 螢幕正確的位置
Modal.ContentModal 的內容
Modal.Overlay主要將背景進行遮罩,通常會使頁面內容變暗。

使用方式

() => (
<Modal.Root>
<Modal.Trigger />
<Modal.Portal>
<Modal.Overlay />
<Modal.Content />
</Modal.Portal>
</Modal.Root>
);

組件資料模型

Modal 本身沒有太多狀態需要進行管理,本篇將使用 ControlledState 進行開啟與關閉的狀態管理。

組件 API

General Props

屬性名稱型別描述
childrenReact.ReactNode組件的子組件
classNamestring自定義 class
asReact.ElementType自定義 HTML tag
屬性名稱型別描述
defaultOpenstring預設 Modal 是否開啟
openstring控制 Modal 是否開啟
onOpenChange(value: string) => void當 Modal 狀態改變時觸發

`Modal.Overlay`

屬性名稱型別描述
containerHTMLElement指定掛載的 DOM 元素,預設為 document.body

`Modal.Trigger`

屬性名稱 (data-attribute)型別描述
[data-state]stringopen / closed

`Modal.Overlay`

Data Attribute

屬性名稱 (data-attribute)型別描述
[data-state]stringopen / closed

實作重點

本次實作會以三種方式實現 Modal 組件,並分別探討其優缺點。

  1. 背景鎖 (Background Lock): 當 Modal 顯示時,背景要是可以選擇不能滾動。
  2. 聚焦鎖 (Focus Lock): 當 Modal 顯示時,聚焦要在 Modal 內,並且不可以離開 Modal,而當 Modal 關閉時,聚焦要先前的聚焦位置。
  3. 動畫 (Animation): Modal 顯示與關閉時,要有動畫效果。
  4. 遮罩 (Backdrop): 當 Modal 顯示時,背景要有遮罩效果。

1. 使用 React.createPortal 實作

使用 React API React.createPortal 來實作 Modal,我們就必需處理可訪問性的問題,像是聚焦與鍵盤交互。但同時我們可以擁有更多彈性去客製化 Modal 的行為與樣式。

實作會用到以下幾個先前章節有提過的概念:

  1. ControlledState:用來管理 Modal 的開啟與關閉狀態。
  2. 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>
  );
};

優點

  1. DOM 層次自由性:可以將子組件渲染到任何 DOM 節點,不受父組件 DOM 結構的限制。
  2. 客製化:可以更客製化 Modal 的行為與樣式。
  3. 模組化:Modal 是屬於 Overlay 的一種,可以將 Overlay 進行模組化,並且可以重複到其他組件上,像是 Tooltip, Modal Sheet, Alert 等等的。

缺點

  1. 需要開發者處理可訪問性:比如聚焦管理和鍵盤導航。

2. 使用原生 <dialog> 進行實作

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>
  );
};

優點

  1. 可訪問性<dialog> 元素會自動處理可訪問性問題,包含聚焦管理和鍵盤導航。
  2. 簡單:不需要額外的 DOM 結構,只需要一個 <dialog> 元素就可以了。
  3. 原生支援:因為是 HTML 標準的一部分,其會有更好的性能和較少的兼容性問題。

缺點

  1. 樣式較難客製化:相比於 React 組件,<dialog> 的樣式和行為定制選項較少。
  2. 瀏覽器支援性:雖然現代瀏覽器大多支持 <dialog>,但在一些舊瀏覽器中可能需要 polyfills。

Accessibility

以下皆是參考 WAI-ARIA 的 Modal Pattern 的規範。

鍵盤交互

鍵盤事件描述組件支援
Tab移動聚焦到下一個可聚焦的元素,若聚焦在最後一個元素,則移動到第一個元素<FocusScope />
Shift + Tab移動聚焦到上一個可聚焦的元素,若聚焦在第一個元素,則移動到最後一個元素<FocusScope />
Escape關閉 Modal<Modal />

ARIA 屬性

屬性名稱型別描述組件
rolestringdialog,讓輔助技術知道這是對話框,可以使用 aria-labelledby<Modal />
aria-hiddenbooleantrue,讓輔助技術知道 Modal 關閉時,不需要將 Modal 內容讀出來。<Modal />
aria-modalbooleantrue,防止輔助技術讓使用者感知到對話框以外的內容 。<Modal />