主題: Web platform

Popover API:少一點 UI 狀態,不是免費的無障礙

原生 Popover 能取代一部分 toggle、click-outside 與 z-index 程式碼;但它始終是 non-modal UI,不會替你決定語意、內容或產品流程。

動態迷因(展開/收合)
Popover 或 dialog 不是同一個 UI 換兩種標籤;先選擇使用者能否繼續和頁面互動。 · 來源:GIPHY

前端最熟悉的一小段 boilerplate,大概是這樣:一個 isOpen state、按鈕 click handler、document-level click-outside、Escape listener、再加上不斷升高的 z-index。每個都合理;累積起來,卻常只是為了顯示一段輔助說明或一個小選單。

Popover API 值得注意,不是因為它讓 popup 更酷,而是它把這種常見行為交回瀏覽器。MDN 將它列為 Baseline 2025:最新裝置與瀏覽器已可用,但舊版 client 仍可能不支援。

我的判斷原則是:

需要暫時、non-modal 的補充內容時,先用 Popover;需要使用者完成決定或表單、而且頁面其他地方不該繼續操作時,用 <dialog>。原生行為能少寫狀態,不會替你選好互動語意。

最小範例,少掉的是協調工作

一個宣告式 Popover 不需要 React state,也不需要全域 click listener:

<button popovertarget="save-help">儲存什麼?</button>

<div id="save-help" popover="auto">
  <p>我們只會儲存草稿與你明確送出的資料。</p>
  <button popovertarget="save-help" popovertargetaction="hide">
    關閉
  </button>
</div>

popover 讓元素成為 Popover;popovertarget 把按鈕和目標接起來。沒有指定 popovertargetaction 時,按鈕預設為 toggle。auto 是預設狀態,支援 light dismiss:使用者點外面或按 Escape 能離開。

瀏覽器也會把它放到 top layer,不必再用某個「一定比 navbar 大」的 z-index 常數猜誰應該蓋在誰上面。當 invoker 與 Popover 以這種方式連結,鍵盤 tab 順序與焦點回到 trigger 的基本行為也由瀏覽器處理。

這正是適合交給 platform 的範圍:

  • 非破壞性的說明、teaching UI、通知摘要。
  • 不必鎖住背景的 action menu 或 content picker。
  • 顯示後仍允許使用者繼續閱讀或操作頁面。

但「少寫 JavaScript」不是選用 popover 的唯一理由。先問使用者可不可以忽略這塊內容;如果答案是可以,Popover 很可能對。如果不可以,它就不是 dialog 的省碼版。

Popover 與 dialog 的邊界,是 modal,不是外觀

兩者都能浮在內容上方,所以最容易被當成同一件事。差別在 interaction contract。

Popover 永遠是 non-modal:頁面其他部分仍可互動。<dialog>showModal() 開啟時,瀏覽器會把其餘內容設為 inert,並提供 modal semantics。這很適合刪除確認、付款、必填流程或任何「先處理這件事才能繼續」的工作。

情境 選擇 原因
「這個欄位會儲存什麼?」 Popover 使用者可以看完繼續填表
通知/帳號的小選單 Popover 背景仍可操作,離開成本低
刪除專案確認 <dialog> modal 不該讓危險動作和背景操作同時發生
多欄位設定或支付步驟 <dialog> modal 需要明確焦點與完成/取消流程

若你真需要 dialog 的語意、但不想 block 背景,HTML 也允許 <dialog popover>。這不是理由把所有 overlay 都改成 dialog;它只是提醒我們:element semantics 和顯示/關閉行為是兩個獨立決定。

動態迷因(展開/收合)
原生 Popover 能拿掉 click-outside 與 z-index 儀式;不需要為一段輔助文字再裝一個 overlay library。 · 來源:GIPHY

它有 a11y 預設,不是 a11y 成品

原生 invoker 關係會建立 aria-expandedaria-details 的隱含關聯,並協助焦點前進與回到 trigger。這很有價值,但它沒有回答下面幾題:

  • 這是 tooltip、menu、alert 還是一段互動表單?內容與 role 仍要符合真正目的。
  • 手機、鍵盤與螢幕閱讀器使用者是否都能觸發與離開?只靠 hover 的資訊通常不是好 default。
  • 若內容有重要動作,是否有看得見、可點擊的關閉/取消路徑?
  • trigger 的文字是否說清楚會發生什麼?「更多」通常不如「查看儲存方式」可理解。

尤其別把 title attribute 當作 tooltip 設計。它在 touch 與 keyboard 上都不可靠,內容也很難被一致地探索。若說明重要,就用真正可操作的 button 與 Popover;若不重要,通常不用另外藏一層。

Progressive enhancement 的成本,要和重要性一起算

Baseline 是很好的預設訊號,不是「所有 client 都能看到」的保證。對於非關鍵教學、可忽略的提示,直接用 Popover 往往已經是最簡單的 progressive enhancement。

若功能不能缺席,不要先寫一個 full polyfill。先保留能完成任務的 inline copy、普通頁面或 <dialog> flow,再按需要做 feature detection:

const supportsPopover = Object.hasOwn(HTMLElement.prototype, "popover");

只有真的需要兩條 interaction path 時才使用這個分支。想確認是否已開啟時,可用 :popover-open;需要在開關後同步 analytics 或其他 UI 時,再聽 toggle event。不要為了顯得「platform-native」把每個狀態又搬回一層 JavaScript state。

一個適合 code review 的檢查表

準備加新 overlay 時,先過這五題:

  1. 使用者能不能繼續操作背景?不能就從 modal dialog 開始想。
  2. 是否只是顯示/隱藏一小塊 non-critical content?是的話,原生 Popover 優先。
  3. 有沒有用標準 button 作為 trigger,並提供明確關閉方式?
  4. 支援不足的舊 client,使用者還能完成主要任務嗎?
  5. 是否真的需要額外 library,還是只是在重做 platform 已提供的行為?

Popover API 最好的效果不是把 UI 變得漂浮,而是把互動 contract 變得誠實。需要 background 仍可操作的,就交給 Popover;需要暫停並做決定的,就用 dialog。剩下的 state、document listener 和 z-index 競賽,能不寫就別寫。


外部參考資料