Popover API 與原生 <dialog>:告別從零手寫的 modal
Popover API 與原生 <dialog>:告別從零手寫的 modal
在講組件無障礙設計的那篇文章裡,我展示過一個 modal,它視覺上因為 display: none 而消失了,但在可訪問性樹裡卻依然存在,因為有人把它隱藏得不對。這只是用 <div> 從零打造的 modal,要真正稱得上無障礙就必須自己動手解決的五個問題之一:焦點陷阱(Tab 不能把用戶帶到 modal 之外)、用 Escape 鍵關閉、點擊內容以外的地方關閉、把焦點交還給打開 modal 的那個元素,以及真正渲染在頁面其他內容之上——不管中途有多少組件用了 overflow: hidden 或者自己的 z-index。
jsx
// 看起來像 modal——但上面五個問題一個都沒解決const NaiveModal = ({ isOpen, onClose, children }) => {if (!isOpen) return null;return (<div className="modal-backdrop" onClick={onClose}><div className="modal">{children}</div></div>);};
這段程式碼沒有焦點陷阱——鍵盤用戶按 Tab 可以自由地被帶回頁面其他部分,儘管視覺上 modal 依然遮住了一切。它不會被 Escape 關掉。關閉後也不會把焦點交還給觸發它的元素。而且它依賴一個假設:DOM 樹裡沒有任何祖先元素有 overflow: hidden,也沒有比頁面其他元素更低的 z-index——在大型應用裡,這個假設經常會被證明是錯的。像 Radix 或 Headless UI 這類 library 存在的意義,正是為了在 JavaScript 裡一次過、好好地解決這個問題。而過去幾年,這個問題有一部分已經由瀏覽器自己解決了,一行 JS 都不用寫。
Top layer:z-index 管不到的一層
<dialog> 和 popover 屬性共同依靠的關鍵機制,是 top layer——瀏覽器內部一層渲染層,實際上位於整份文件之上,完全不受 z-index、overflow 或 stacking context 影響。一個被提升到 top layer 的元素,並不是「z-index 特別高」——它已經不再屬於頁面正常的層疊堆疊,所以任何有 overflow: hidden 的祖先都無法裁切它,任何 z-index: 9999 這種誇張數值的相鄰元素也蓋不住它。這正是瀏覽器一直以來內部用在 <video> 全螢幕模式之類原生元素上的同一個機制——Popover API 和 <dialog> 只是把它開放給開發者使用。
jsx
const ConfirmDialog = ({ onConfirm }) => {const dialogRef = useRef(null);return (<dialog ref={dialogRef} className="confirm-dialog"><p>確定要刪除這個項目嗎?</p><form method="dialog" className="confirm-dialog__actions"><button type="submit" value="cancel">取消</button><button type="submit" value="confirm" onClick={onConfirm}>刪除</button></form></dialog>);};// 在組件的其他地方:// dialogRef.current.showModal();
呼叫 .showModal()(不是 .show()——這是常見的誤用,.show() 打開的是非模態 dialog,沒有背景,也沒有焦點陷阱)會同時免費啟動四件事:提升到 top layer、渲染出一個把頁面其他部分變暗的 ::backdrop 偽元素、焦點陷阱(Tab 只會在 dialog 內部循環),以及把文件其餘部分變成 inert——dialog 以外的元素不再能被聚焦,對螢幕閱讀器來說也從互動中消失,儘管它們在變暗的背景下依然看得見。用 Escape 關閉,或者透過 <form method="dialog"> 關閉(這是原生機制——在這種表單裡點擊 submit 按鈕,會關閉 dialog,並把 dialog.returnValue 設成被點擊那個按鈕的 value,不需要 preventDefault,也不需要在 JS 裡寫 handler),都會自動把焦點交還給打開 dialog 的那個元素。這四件事沒有一件需要你自己動手實作。
Popover API:同樣的效果,但不具模態性
<dialog> 假設頁面其餘部分應該被鎖住。這對確認刪除之類的場景很合理,但對下拉選單、tooltip 或 toast 這類東西來說就太重了——這些東西應該顯示在其他內容之上,點擊旁邊就自動關閉,但不應該阻擋用戶跟頁面其餘部分互動。popover 屬性就是為此而設,聲明式,一行 JavaScript 都不用寫:
html
<button popovertarget="user-menu">帳戶</button><div id="user-menu" popover><a href="/profile">個人資料</a><a href="/settings">設定</a><button popovertarget="user-menu" popovertargetaction="hide">登出</button></div>
光是 popovertarget/id 這組關係,就足以讓瀏覽器處理好其餘一切:提升到 top layer、light dismiss(點擊 popover 以外的任何地方,或者按 Escape,都會自動把它關掉,不需要在 document 上監聽 click),以及 trigger 跟內容之間正確的 ARIA 關係(瀏覽器會根據這組屬性自己接上 aria-expanded 和 aria-controls)。這正是以前需要 Floating UI 這類 library,或者自己手動監聽元素外點擊、再加上 event.target.closest() 判斷才能做到的事。
跟模態的 <dialog> 相比,根本的分別在於:popover 不會把頁面其餘部分變成 inert,也沒有焦點陷阱。這是規格上刻意的設計決定,不是功能缺失——用戶選單不應該像真正的 modal 那樣,阻止用戶捲動頁面或點擊其他地方。如果把這兩個工具用反了——在內容真的必須鎖住其餘介面的地方(例如確認一個不可逆的操作)用了 popover——就會得到一個介面,讓鍵盤用戶隨時都能用 Tab 跳出這個其實根本沒有鎖住他的「modal」。
文件有時不提的陷阱:點擊 <dialog> 的背景不會自動關閉它
儘管 ::backdrop 是免費渲染出來的,點擊它不會在沒有額外程式碼的情況下關閉 dialog——這是少數幾件需要自己動手補寫的事情之一。能做到這一點的機制,靠的是一個具體的 hit-testing 事實:處於模態模式的 <dialog> 元素本身,在 hit-testing 上會填滿整個可見區域,不管它裝內容的那個 box 視覺上有多小——所以點擊變暗的背景,命中的依然是 event.target === dialogElement,而點擊裡面的內容,命中的則是具體的子元素。
jsx
const handleBackdropClick = (event) => {// 只有點擊命中背景時,event.target 才會是 <dialog> 本身,// 而不是它裡面任何一個內容元素if (event.target === dialogRef.current) {dialogRef.current.close();}};// <dialog ref={dialogRef} onClick={handleBackdropClick}>
這個技巧值得單獨記住,因為直覺上會覺得「背景既然是自動渲染出來的,應該也會自動關閉」——它不會,而這也是整套機制裡唯一需要你親手寫一行 JS 的地方。
常見陷阱與最佳實踐
.show()跟.showModal()不是同一回事。.show()打開的是非模態 dialog——沒有::backdrop,沒有焦點陷阱,也不會讓頁面其餘部分變成inert。要做真正的 modal,永遠都要用.showModal()。<dialog>的預設樣式需要有意識地重設,就跟無障礙設計那篇文章提到的每一個原生元素(<button>、<ul>)一樣——瀏覽器會給它預設的border、padding和置中效果,實務上你幾乎每次都會用自己的 CSS 覆蓋它們。- 在需要模態性的地方,popover 不能取代
<dialog>。 沒有焦點陷阱、沒有inert,是 popover 刻意設下的限制,不是可以繞過的漏洞——如果內容必須鎖住頁面其餘部分,正確的工具永遠是<dialog>.showModal()。 - 支援程度:
<dialog>早已穩定,popover比較新。<dialog>(包括showModal())從 2022 年開始就有穩固的支援。popover屬性則比較晚,在 2024 年才達到 Baseline(廣泛可用)——如果專案要顧及大量舊裝置,在完全放棄 JS library 作為 fallback 之前,仍然值得先確認一下最低要支援的 Safari 版本。
總結
用 <div> 從零手寫的 modal 之所以不好,不是因為開發者不夠用心——而是因為它嘗試用 JavaScript 重新做出一套瀏覽器現在原生、免費就提供的機制:不受 z-index 影響的 top layer、焦點陷阱、讓頁面其餘部分變成 inert、::backdrop、把焦點交還給觸發元素。<dialog> 和 popover 不是同一件事的兩種變體——它們是站在同一條界線兩側的兩個工具:一個是有意識地鎖住其餘介面的模態性,另一個是有意識地不鎖住它的輕量、暫時性內容。在兩者之間做選擇,不是風格問題,而是要回答一個問題:此刻用戶是否應該還能在頁面上做其他事情——而這個問題值得在寫下第一行程式碼之前先想清楚,而不是等到事後才發現選單擋住了捲動,或者確認刪除帳戶的 modal 根本擋不住 Tab 鍵。