主題: Web platform

<details> 不只是省幾行 JS:Disclosure 跟 Accordion 的邊界

原生 details 已經處理開關與鍵盤操作;name 能做出互斥面板,但只有內容語意與舊瀏覽器退化都合理時才該使用。

動態迷因(展開/收合)
只想揭露一段補充資訊時,別先替它套一層 state、button 和 listener 的骨架;原生 <details> 已經會開關。 · 來源:GIPHY

一個 FAQ 或補充說明,常常從一個 isOpen 開始,最後長出 click handler、鍵盤邏輯、aria-expanded、動畫狀態與測試矩陣。這些不是錯;問題是,我們常在確認需求前就先重做一次瀏覽器已經會做的事。

<details> 的價值不在於「永遠不用 JavaScript」,而在於它提供一個很小、很明確的 contract:使用者按下 summary,取得額外資訊。瀏覽器負責開關、鍵盤操作與基本語意;作者只要把被揭露的內容講清楚。

我的規則是:

補充內容可以獨立展開時,先用 <details>;只有「目前開哪一項」本身就是產品狀態,或互動已經不是 disclosure 時,才做自訂 accordion。

先把它當 disclosure,不是抽屜元件

最小寫法已經有可點擊的 control。不需要替 summary 再包一個 button,也不用手動維護 aria-expanded

<section aria-labelledby="shipping-title">
  <h2 id="shipping-title">配送</h2>

  <details>
    <summary>何時會收到追蹤編號?</summary>
    <p>包裹交給物流商後,會以電子郵件通知。</p>
  </details>
</section>

這個結構適合 FAQ、技術註解、可略過的進階設定、錯誤細節與長內容中的旁支說明。它不假裝內容消失了;使用者仍看得到一個有意義的問題,決定是否展開答案。

幾個小規則比花俏動畫重要:

  • summary 必須是第一個子元素,文字要說明「展開後會得到什麼」,不要只寫「更多」。
  • 把真正的 section heading 放在 summary 外面。MDN 指出某些瀏覽器/輔助技術組合會讓 summary 內的 heading role 不一致。
  • 不要把 persistent menu、刪除按鈕或其他互動控制塞進 summary。一個 trigger 裡塞另一個 trigger,通常表示這不是單純的 disclosure。

原生元素不是免檢查通行證;它只是先替你把最常見、也最容易漏掉的部分做好。

name 能做 accordion,但先問「只能開一個」是不是需求

同一組 <details> 指定相同的 name,瀏覽器會在開啟其中一項時關閉另一項:

<section aria-labelledby="plan-title">
  <h2 id="plan-title">方案說明</h2>

  <details name="plan">
    <summary>個人方案</summary>
    <p>適合單人使用與小型專案。</p>
  </details>

  <details name="plan">
    <summary>團隊方案</summary>
    <p>提供共用權限與集中管理。</p>
  </details>
</section>

這是很好的小功能,但不該自動套在每一份 FAQ。讀者比對兩個答案、回頭查看步驟,或同時開著多個 troubleshooting 區塊時,「只能開一個」反而是摩擦。

name 前先回答一句人話:使用者打開第二項時,第一項消失會不會讓事情更好? 如果答案不是明確的會,就讓每一項獨立開著。

<details> 的基本開關已廣泛支援;name 分組則要照產品的 browser matrix 檢查。這裡很適合 progressive enhancement:舊瀏覽器若沒有互斥行為,內容仍可展開與閱讀。若「同時只能開一個」是不可妥協的工作規則,就不要把它押在一個無 JS 的便利屬性上。

動態迷因(展開/收合)
需要 tabs、外部同步 state、額外 action,或不可妥協的互斥規則時,別讓 <details> 硬撐;換成真正符合需求的 control。 · 來源:GIPHY

這些情況,自己做 accordion 比較誠實

HTML Standard 也提醒:<details> 代表 disclosure,不是 tabs 或 menu 的萬用替身。以下需求一出現,自訂實作通常更清楚:

需求 為何不是單純 <details> 起點
分頁切換,內容彼此替代 使用者是在選取 view,不是在揭露補充資訊 tabs pattern
header 旁有固定 action 一個 summary 不該同時包住多個互動 control heading + button + 獨立 action
開啟狀態要進 URL、跨頁保存或多人同步 open 已成為應用程式狀態 明確 state model
未支援 name 時也必須永遠只開一項 退化成多開會破壞任務 自訂 accordion + 測試
需要特定方向鍵操作、focus 管理或複雜動畫 互動 contract 已超出原生 disclosure 依 WAI-ARIA pattern 實作

這裡的重點不是「原生不夠強」,而是不要把省下的狀態管理又拿去模仿另一種 widget。當需求真的是 accordion,WAI-ARIA Authoring Practices 對 heading、button、aria-expandedaria-controls 與鍵盤行為都有明確要求;那時候用一個可測試的 component 比在 <details> 上疊例外更便宜。

JavaScript 留給同步,不留給開關本身

如果真的需要在展開後載入昂貴資料、記錄使用情況,或同步另一個不影響核心任務的 UI,toggle event 已經是乾淨的接點:

const details = document.querySelector("#release-notes");

details.addEventListener("toggle", () => {
  if (!details.open) return;
  loadReleaseNotesOnce();
});

這段程式不接管開關;它只在瀏覽器已經完成狀態轉換後補上真正額外的工作。也因此測試可以先驗證內容和 keyboard toggle,再驗證資料載入是否只在需要時發生。

把 JavaScript 放在這個位置,通常比一開始就把 open 複製到 framework state 更容易維護。唯一例外是:你真的需要 framework state 成為 source of truth。那就正面承認它,別兩邊各存一份。

Code review 時只問五題

  1. 這段內容是補充資訊,還是另一個可選 view?
  2. 使用者會不會需要同時打開兩項比較或操作?
  3. summary 是否用可理解的文字說明展開結果?
  4. 不支援 name 的 client,多開是否仍可完成任務?
  5. 若答案需要自訂,是否已按真正 widget 的 a11y contract 寫測試?

<details> 最漂亮的地方,是它讓「先做內容可讀,再增加互動」變成預設。把它用在 disclosure,就能少掉一層 state 和一串 listener;需求超出時,換正確的 control。兩種選擇都比硬塞一個萬用 accordion 誠實。


外部參考資料