Dialog 的魔法

HTML 在 2014 的 HTML5 之後,其實就很少有什麼新的標籤,比較多是在各種細節的釐清和標準化各種未定義行為,不過在這些屈指可數的新標籤中,有一個比較廣為人知的,就是 <dialog>

<dialog>的出現和前端工程實務上的發展很有關係,我個人認為它其實就是古早時候的confirmalert的現代版本,十多年前大部分的網路服務有類似需求還是直接用這兩個原生的互動介面為主,不過因為是原生的,對開發者來說可控性很低,加上以前的版本很容易會把瀏覽器卡死,使用體驗也不太好,現在的版本則考慮到使用者隱私也多了很多限制,加上樣式無法客製,所以在前端工程開始熱烈發展之時,很多網站也都開始自己做自己的對話框了;其中,最具代表性的或許是當年紅極一時的 Bootstrap 的 modal 了吧。

這些各家自製的對話框都很不錯,設計漂亮、功能完善,不過它畢竟沒有魔法(那些無法透過網路標準辦到的東西),有幾個問題還是無法克服,首先第一個就是,它無法永遠保持在最上層;第二個就是,使用者還是有可能意外 focus 跑到 modal 對話框之外的元素上;最後一個是,<dialog>是如何沒有外層元素,卻可以垂直水平置中且本身大小是非固定的?其實我一開始就是對最後這個問題感到疑惑,才開始認真的研究<dialog>,結果一研究下去,發現超多的細節的。

在回答問題之前,先來介紹一下<dialog>吧,首先<dialog>預設樣式是display: none;隱藏起來的,不考慮直接改 CSS 的話,要讓它出現有幾種方法,首先是透過open這個專屬的新 attribute:

<dialog open>
   This is a dialog
</dialog>

這樣就會讓<dialog>出現在畫面上,表現就如同一般的<div>標籤,第二種方法則是透過 JavaScrpt:

dialogNode.show()

這樣的效果和open一樣,然後除了這兩個方法之外,還有一個最特別的:

dialogeNode.showModal()

showModal會讓這個<dialog>出現在網頁的最上層,並且保持置中,而且同時,網頁上除了該<dialog>以外的地方都會無法互動。

在這邊,一下子三件以前辦不到的事情都出現了:

  • 保證在網頁的最上層
  • 只有一層標籤就水平垂直置中
  • 其他地方都無法互動

那麼,這些事情是如何辦到的呢?首先就來說說第一點吧,如何能夠保證<dialog>一定在最上層呢?那就是一個新規範的東西了,叫做 top Layer,標準則是放在 CSS Positioned Layout Module Level 4,這東西指的現在瀏覽器在繪製文件之餘,還要建立一個獨立於文件之外的 stacking context,然後該 context 一定是在其他所有 stacking context 之上,尺寸則是和 viewport 一樣大,這個 stacking context 就是 top layer 了,開發者是無法直接控制該 context,需要透過一些特定 API 操作,才能把東西丟到 top layer 來繪製,目前有用到 top layer 的除了<dialog>之外,文件上是還有寫到 fullscreen API 和還在初期的 popover,開發工具都已經有支援,下圖的開發工具的截圖中,文件的尾端就多了一個 top layer。

根據前一點,可以知道其實<dialog>一樣是普通 DOM 節點,只是在用showModal()時會放在不同的 stacking context,一樣可以用 CSS 設定樣式,那麼,<dialog>是如何只有一層標籤就置中的呢?這當中可沒有魔法,其實很簡單,也是一個很古早的水平垂直置中的方法之一:

dialog {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  margin: auto;
}

其實就是絕對定位 + 上下左右都設為 0 + margin 設定為 auto 的方法,等等,如果垂直水平置中這麼簡單,那大家怎麼會痛苦了幾十年還一直在尋求一個物件置中的方法?其實原因就在於,這個方法有個先決條件,就是該標籤的寬高必須是要明確定義的值,而不能是 auto,詳細的計算方式規範目前在 CSS Position Level 3 裡面的第五章「Old Absolute Positioning Layout Model」,更早則是 CSS 2 就已經有了,這一個章節是在介紹如果是絕對定位的標籤,要怎樣計算它的位置、寬高和 margin,有個表格詳細的列出各種情境的排列組合(是否為auto):

以 modal dialog 的例子來看怎樣計算水平的定位吧:首先來看屬性的組合,left 和 right 都是有值,值為 0、margin-left 和 margin-right 都是 auto,如果寬度是有值的話,那就是表格第二行中的第三項,算法就是:

margins split positive free space

剩餘的空間平分給兩個 margin auto,結果就會是水平置中;如果 width 是 auto 的話就是最下面一行:

auto margins → zero
solve for auto

意思就是 margin 的 auto 都為 0,剩下的空間都給設為 auto 的 width。這表格也解釋了為何有些marign: auto會有元件置中的效果,其實這個問題也在我腦中好久了,沒想到計算方法在 CSS 2 就已經有明確的定義好了。

回來看<dialog>,它在 modal 模式下,是會水平垂直置中的,但是它的寬高表現也很像是用auto一般,是根據內容變化的,那到底是怎樣達成這樣的效果的呢?其實關鍵就是它的寬高是一個新的值:fit-content,完整的樣式應該是:

dialog {
  position: fixed;
  inset: 0; /* shorthand of top, right, bottom, left */
  margin: auto;
  width: fit-content;
  height: fit-content;
}

結果就是,因為 width 不是 auto,所以套用的規則就是表格中的第二行中的第三項,根據標籤內容決定標籤的寬度之後,剩下的空間就平分給 margin,達成置中的效果。

了解原理之後,就可以知道哪些 CSS style 宣告是和定位有關的,除了可以避免不小心覆蓋掉(我最最一開始就是因為自訂 CSS 讓它定位不正常才開始研究的),還可以隨心所欲的調整標籤的位置,舉例來說,我的版面有一個 sidebar 佔去左邊的 300px,然後我希望<dialog>是在剩下的主要區域內置中,那我就把 left 設為 300px 就好了。

終於來到第三個問題,是怎樣讓其他地方都無法互動的呢?這就也是一個<dialog>的特異功能,它有一個隱藏的 is modal flag,呼叫showModal時,除了會把該 flag 設為true之外,還可以讓文件中其它部分變為和有inert時一樣,什麼是 inert 呢?中文翻譯為惰性,可以讓互動元件失去活性,使用時機就是如果互動元件因為一些原因需要暫時性的停用,例如收合起來的目錄,這時候我們不希望使用者的 focus 移動到目錄內,甚至不小心點擊到,那就可以用inert這個屬性讓它失去活性,使用者的游標就永遠不會跑進去,也不會有互動事件。

然後研究到這邊,我突然想起另外一個在腦中疑惑許久的問題,modal 這個單字到底是哪裡來的?為什麼 Bootstrap 也是選這個字?然後為什麼 HTML 不就 dialog 這個字用到底呢?進一步搜尋之後才發現,原來這邊的 modal 不是指 UI 元件,而是一種流程,這個 modal 其實是 modal window(dialog) 的 modal,而這個 modal 是專指應用程式中一種特定的模式(modal 的字源即為 mode),在該模式之下,除了 modal window 之外的元件都被停用,使用者必須要先完成 modal window 內的互動才可以繼續。這就很像是古早時候的confirmalert一樣,不把它關掉就無法繼續用網頁,然後我也才理解為什麼會標準文件內還會提到 autofocus 流程、還有為什麼會有returnValue這個屬性可以用。

其實 Chrome 早在 2014 年就支援<dialog>了,然後還提供了個 polyfill(當然無法解決 top layer 的問題),不過 FirefoxSafari 則是要到 2022 才支援,我去找出 Firefox 的 issue 來看,一路看下來,Firefox 是約 2016 開始,斷斷續續的實作,在實做的這段期間,其實標準也一直有修改,Safari 和 Firefox 都是 2022 才正式支援,到 2022 正式支援時的版本已經蠻穩定了,所以可以說是直到 2022,<dialog>才是真正意義上的可用,我則是直到今年才有考慮拿來用,然後也才生出了這篇文章,其實<dialog>還有很多細節,像是 ::backdrop:modalformmethod="dialog"returnValue、怎麼用 cancel事件ESC 等,雖然內文礙於篇幅沒有介紹,不過我有用 codesandbox 做了個簡單的範例,有興趣的可以去玩看看。

最後,如果和我一樣是寫 React 要用 modal 模式的<dialog>的話,就一定要用到useEffect,不能只操作open屬性,實作其實很簡單,我隨意搜尋一下也有看到一個 use-html-dialog ,也可以直接參考它的原始碼,關鍵的部分:

useEffect(() => {
  if (openModal) {
    ref.current?.showModal();
  } else {
    ref.current?.close();
  }
}, [openModal]);

其他參考資料: