為 WordPress 分類清單實作手風琴展開收合功能

前言

在 WordPress 網站中,分類清單常用於側邊欄或導覽列,當分類層級較多時,使用者體驗會因為清單過長而降低。這段程式碼的目標是為多層分類清單加入手風琴(Accordion)功能,使得使用者可以展開或收合子分類,提升導覽的清晰度與操作便利性。本文適合有基礎 JavaScript 與 DOM 操作經驗的前端工程師或自學者,想了解如何結合無障礙設計(ARIA)與動態 DOM 操作來優化分類清單。

目標與使用情境

這段程式碼主要解決以下問題:

  • 多層分類清單展開收合控制
  • 一次只允許展開一個分類(手風琴嚴格模式)
  • 兼顧無障礙操作,使用 ARIA 屬性提示狀態
  • 保持原有連結功能不受影響

適用於 WordPress 產生的分類清單(如 .wp-block-categories.wp-block-categories-list),並且希望加強使用者操作體驗與無障礙支援。

主要流程解析

1. 選取目標清單容器

const roots = Array.from(document.querySelectorAll('.wp-block-categories, .wp-block-categories-list'));
if (!roots.length) return;

這裡先找出所有符合條件的分類清單容器,若無則直接結束,避免不必要的後續操作。

2. 設定手風琴模式

const singleOpen = true;

設定是否一次只允許展開一個分類項目,true 表示嚴格手風琴模式。

3. 標記並處理有子分類的項目

const parents = Array.from(root.querySelectorAll('li.cat-item > ul.children'))
  .map(ul => ul.parentElement)
  .filter(li => li && li.matches('.cat-item'));

找出所有有子清單的分類項目(li.cat-item 中包含 ul.children),這些項目需要被加上展開收合功能。

4. 加入切換按鈕與 ARIA 屬性

const toggleBtn = document.createElement('button');
toggleBtn.className = 'cat-toggle';
toggleBtn.type = 'button';
toggleBtn.setAttribute('aria-expanded', 'false');
toggleBtn.setAttribute('aria-controls', subId);
toggleBtn.setAttribute('title', '展開/收合');

為每個有子分類的 li 加入一個按鈕,用於切換展開狀態,同時透過 aria-expandedaria-controls 屬性提升無障礙體驗。

5. 切換展開收合狀態的邏輯

function toggle(open) {
  const willOpen = (typeof open === 'boolean') ? open : !li.classList.contains('is-open');
  if (willOpen) {
    if (singleOpen) {
      const siblings = Array.from(li.parentElement.children)
        .filter(el => el !== li && el.classList && el.classList.contains('is-open'));
      siblings.forEach(sib => {
        sib.classList.remove('is-open');
        const btn = sib.querySelector(':scope > .cat-toggle');
        if (btn) btn.setAttribute('aria-expanded', 'false');
      });
    }
    li.classList.add('is-open');
    toggleBtn.setAttribute('aria-expanded', 'true');
  } else {
    li.classList.remove('is-open');
    toggleBtn.setAttribute('aria-expanded', 'false');
  }
}

此函式負責切換展開狀態,若啟用嚴格手風琴模式,會同時關閉同層其他已展開的分類。狀態透過 is-open 類別與按鈕的 aria-expanded 屬性同步更新。

6. 綁定事件與點擊行為控制

  • 按鈕點擊時切換展開狀態
  • 點擊非連結與非按鈕的 li 項目空白處也能切換展開,避免影響原本連結導頁
toggleBtn.addEventListener('click', function(e) {
  e.preventDefault();
  e.stopPropagation();
  toggle();
});

li.addEventListener('click', function(e) {
  const isLink = e.target.closest('a');
  const isBtn  = e.target.closest('.cat-toggle');
  if (!isLink && !isBtn) {
    e.preventDefault();
    toggle();
  }
});

7. 預設展開目前分類項目

若分類項目本身或其子分類包含 current-catcurrent-cat-parent 類別,則預設展開,方便使用者辨識目前所在分類。

if (li.classList.contains('current-cat') ||
    li.classList.contains('current-cat-parent') ||
    sub.querySelector('.current-cat, .current-cat-parent')) {
  toggle(true);
}

實務應用與優化方向

  • 可依需求調整 singleOpenfalse,允許多個分類同時展開
  • 按鈕樣式與動畫可加入 CSS 過渡效果,提升使用者體驗
  • 可擴充鍵盤操作支援,增強無障礙友善度
  • 若分類清單動態更新,需額外處理事件重新綁定

常見問題與注意事項

  • 插入按鈕位置需謹慎,避免破壞原本的連結結構
  • 使用 aria-controls 需確保對應的子清單有唯一 ID
  • 點擊事件阻止預設行為,避免影響連結跳轉,需確認邏輯正確

完整程式碼

document.addEventListener('DOMContentLoaded', function() {
  // 你貼的清單容器 class
  const roots = Array.from(document.querySelectorAll('.wp-block-categories, .wp-block-categories-list'));
  if (!roots.length) return;

  // 設定:是否一次只開一個(true = 手風琴嚴格模式)
  const singleOpen = true;

  roots.forEach(function(root) {
    // 標記啟用
    root.classList.add('cat-accordion');

    // 找到有 children 的 cat-item
    const parents = Array.from(root.querySelectorAll('li.cat-item > ul.children'))
      .map(ul => ul.parentElement)
      .filter(li => li && li.matches('.cat-item'));

    // 為每個需要的 li 補上 class 與切換按鈕/ARIA
    parents.forEach(function(li, idx) {
      li.classList.add('has-children');

      const sub = li.querySelector(':scope > ul.children');
      // 建唯一 id,便於 aria-controls 連結
      const subId = sub.id || ('cat-sub-' + Math.random().toString(36).slice(2));
      sub.id = subId;

      // 建立切換按鈕(放在 
<a> 後面,不影響原有點擊導覽)
      const toggleBtn = document.createElement('button');
      toggleBtn.className = 'cat-toggle';
      toggleBtn.type = 'button';
      toggleBtn.setAttribute('aria-expanded', 'false');
      toggleBtn.setAttribute('aria-controls', subId);
      toggleBtn.setAttribute('title', '展開/收合');

      // 插到 a 後(若沒有 a,則插到 li 內最前)
      const anchor = li.querySelector(':scope > a');
      if (anchor && anchor.nextSibling) {
        anchor.parentNode.insertBefore(toggleBtn, anchor.nextSibling);
      } else if (anchor) {
        anchor.parentNode.appendChild(toggleBtn);
      } else {
        li.insertBefore(toggleBtn, li.firstChild);
      }

      // 切換邏輯
      function toggle(open) {
        const willOpen = (typeof open === 'boolean') ? open : !li.classList.contains('is-open');
        if (willOpen) {
          if (singleOpen) {
            // 關閉同層其他
            const siblings = Array.from(li.parentElement.children)
              .filter(el => el !== li && el.classList && el.classList.contains('is-open'));
            siblings.forEach(sib => {
              sib.classList.remove('is-open');
              const btn = sib.querySelector(':scope > .cat-toggle');
              if (btn) btn.setAttribute('aria-expanded', 'false');
            });
          }
          li.classList.add('is-open');
          toggleBtn.setAttribute('aria-expanded', 'true');
        } else {
          li.classList.remove('is-open');
          toggleBtn.setAttribute('aria-expanded', 'false');
        }
      }

      // 綁定按鈕點擊
      toggleBtn.addEventListener('click', function(e) {
        e.preventDefault();
        e.stopPropagation();
        toggle();
      });

      // 如果想讓點 li 標題也能展開(但避免影響連結導頁):
      // 只在點擊「li 空白處」才切換;點到 a 時仍正常導頁
      li.addEventListener('click', function(e) {
        const isLink = e.target.closest('a');
        const isBtn  = e.target.closest('.cat-toggle');
        if (!isLink && !isBtn) {
          e.preventDefault();
          toggle();
        }
      });

      // 進階:如果此項或其子層有 "current-cat" / "current-cat-parent" 類別,預設展開
      if (li.classList.contains('current-cat') ||
          li.classList.contains('current-cat-parent') ||
          sub.querySelector('.current-cat, .current-cat-parent')) {
        toggle(true);
      }
    });
  });
});