WordPressの wp_nav_menu() で出力されるカスタムメニューには、サブメニューを持つ項目に .menu-item-has-children クラスが自動的に付与されます。このサブメニューをアコーディオン形式で開閉できるようにするJavaScriptを紹介します。

特徴

  • クリック型(.clickdion)とホバー型(.hoverdion)の2種類に対応
  • CSSクラスを付けるだけで動作
  • Web Animations APIによる滑らかな開閉アニメーション
  • 連打防止機能付き
  • WordPressメニュー以外の汎用的な構造にも対応

    設計のポイント:クラスは <li> に付ける

    当初は親の <ul> にクラスを付ける設計でしたが、これだと1つのメニュー項目をホバーしたときに主要なサブメニューが開いてしまう問題が発生しました。

    <!-- ❌ 問題のある設計:<ul> にクラス -->
    <ul class="menu hoverdion is-opened">
      <li class="menu-item-has-children">
        <ul class="sub-menu">...</ul>  <!-- 全部開く -->
      </li>
      <li class="menu-item-has-children">
        <ul class="sub-menu">...</ul>  <!-- 全部開く -->
      </li>
    </ul>
    

    解決策として、各 <li> にクラスを付ける設計に変更しました。

    <!-- ✅ 正しい設計:<li> にクラス -->
    <ul class="menu">
      <li class="menu-item-has-children hoverdion">
        <ul class="sub-menu">...</ul>  <!-- この項目だけ開く -->
      </li>
      <li class="menu-item-has-children hoverdion">
        <ul class="sub-menu">...</ul>  <!-- 独立して制御 -->
      </li>
    </ul>
    

    この設計なら各メニュー項目が独立して開閉でき、将来的にネストしたサブメニュー(孫メニュー)にも自然に対応できます。

    使い方

    WordPressメニューでの設定

    WordPress管理画面の「外観」→「メニュー」で、サブメニューを持つ項目の「CSS class」欄に hoverdion または clickdion を追加します。

    ※「CSS class」欄が表示されていない場合は、メニュー編集画面右上の「表示オプション」から「CSSクラス」にチェックを入れてください。

    HTML構造

    WordPressメニュー(ホバー型)

    <ul class="menu">
      <li class="menu-item menu-item-has-children hoverdion">
        <a href="#">BLOG</a>
        <ul class="sub-menu">
          <li class="menu-item"><a href="#">カテゴリー1</a></li>
          <li class="menu-item"><a href="#">カテゴリー2</a></li>
        </ul>
      </li>
    </ul>
    

    WordPressメニュー(クリック型・スイッチ付き)

    <ul class="menu">
      <li class="menu-item menu-item-has-children clickdion">
        <a href="#">SNIPPET<span class="dion-switch"></span></a>
        <ul class="sub-menu">
          <li class="menu-item"><a href="#">項目1</a></li>
          <li class="menu-item"><a href="#">項目2</a></li>
        </ul>
      </li>
    </ul>
    

    汎用的な構造(dl/dt/dd など)

    <dl class="dion-details clickdion">
      <dt class="dion-summary">
        タイトル<span class="dion-switch"></span>
      </dt>
      <dd class="dion-content">
        開閉するコンテンツ
      </dd>
    </dl>
    

    JavaScript コード

    (function () {
      /**
       * _classify-accordion.js v2.0
       * 
       * クリック型(.clickdion)とホバー型(.hoverdion)のアコーディオン
       * 各 <li> または .dion-details の親要素にクラスを付与して使用
       */
    
      // ===========================================
      // CSS スタイル注入
      // ===========================================
      const STYLE = document.createElement("style");
      STYLE.textContent = `
    /* コンテンツ部分の基本設定 */
    :is(.clickdion, .hoverdion) > :is(.dion-content, .sub-menu) {
      overflow: hidden;
      transition: height 0.4s ease-out;
    }
    
    /* 閉じた状態 */
    :is(.clickdion, .hoverdion):not(.is-opened) > :is(.dion-content, .sub-menu) {
      height: 0 !important;
      min-height: 0;
      padding-block: 0;
      border-block-width: 0;
    }
    
    /* リンク内のスイッチがある場合、リンク自体は無効化 */
    :is(.clickdion, .hoverdion) > a:has(.dion-switch) {
      pointer-events: none;
      cursor: default;
    }
    
    /* スイッチ自体はクリック可能 */
    :is(.clickdion, .hoverdion) .dion-switch {
      pointer-events: auto;
      cursor: pointer;
    }
    
    /* トリガー部分の配置 */
    :is(.clickdion, .hoverdion) > :is(.dion-summary, a) {
      position: relative;
    }
    
    /* スイッチ・ステータスアイコンの共通配置 */
    :is(.clickdion, .hoverdion) > :is(.dion-summary, a) :is(.dion-switch, .dion-status) {
      position: absolute;
      top: 0;
      bottom: 0;
      right: 0;
      margin: auto;
      display: flex;
      justify-content: center;
      align-items: center;
      width: 44px;
      height: 44px;
    }
    
    :is(.clickdion, .hoverdion) > :is(.dion-summary, a) :is(.dion-switch, .dion-status)::before {
      display: flex;
      justify-content: center;
      align-items: center;
      font-size: 133.3%;
      font-family: "Material Symbols Sharp";
      font-variation-settings: "FILL" 0, "wght" 200;
    }
    
    /* .dion-switch アイコン(矢印) */
    :is(.clickdion, .hoverdion) > :is(.dion-summary, a) .dion-switch::before {
      content: "\\e313";
      transition: rotate 0.3s ease-out;
    }
    
    :is(.clickdion, .hoverdion).is-opened > :is(.dion-summary, a) .dion-switch::before {
      rotate: -180deg;
    }
    
    /* .dion-status アイコン(+/-) */
    :is(.clickdion, .hoverdion) > :is(.dion-summary, a) .dion-status::before {
      content: "\\e145";
    }
    
    :is(.clickdion, .hoverdion).is-opened > :is(.dion-summary, a) .dion-status::before {
      content: "\\e15b";
    }
    `;
      document.head.appendChild(STYLE);
    
      // ===========================================
      // ユーティリティ関数
      // ===========================================
    
      const isAnimating = (element) => element.dataset.anim === "action";
      const isOpened = (element) => element.classList.contains("is-opened");
    
      const getContent = (element) => {
        return element.querySelector(":scope > .sub-menu, :scope > .dion-content");
      };
    
      const getTrigger = (element) => {
        return element.querySelector(":scope > a, :scope > .dion-summary");
      };
    
      // ===========================================
      // 初期化
      // ===========================================
    
      const initAccordions = () => {
        const clickdions = document.querySelectorAll(".clickdion");
        const hoverdions = document.querySelectorAll(".hoverdion");
    
        clickdions.forEach(setupClickdion);
        hoverdions.forEach(setupHoverdion);
    
        console.log(
          "🟢 classify-accordion.js v2.0: 初期化完了!",
          "clickdion =", clickdions.length,
          ", hoverdion =", hoverdions.length
        );
      };
    
      // ===========================================
      // クリック型アコーディオン
      // ===========================================
    
      const setupClickdion = (element) => {
        const trigger = getTrigger(element);
        const content = getContent(element);
        const dionSwitch = element.querySelector(".dion-switch");
    
        if (!trigger || !content) return;
    
        const clickTarget = dionSwitch || trigger;
    
        clickTarget.addEventListener("click", (e) => {
          e.preventDefault();
          e.stopPropagation();
    
          // 連打防止
          if (isAnimating(element)) return;
    
          toggleAccordion(element, content);
        });
      };
    
      // ===========================================
      // ホバー型アコーディオン
      // ===========================================
    
      const setupHoverdion = (element) => {
        const trigger = getTrigger(element);
        const content = getContent(element);
    
        if (!trigger || !content) return;
    
        let hoverTimeout = null;
    
        const clearHoverTimeout = () => {
          if (hoverTimeout) {
            clearTimeout(hoverTimeout);
            hoverTimeout = null;
          }
        };
    
        const handleOpen = () => {
          clearHoverTimeout();
          if (!isOpened(element)) {
            openAccordion(element, content, true);
          }
        };
    
        const handleCloseWithDelay = () => {
          hoverTimeout = setTimeout(() => {
            if (isOpened(element)) {
              closeAccordion(element, content, true);
            }
          }, 100);
        };
    
        // トリガーのイベント
        trigger.addEventListener("mouseenter", handleOpen);
        trigger.addEventListener("focus", handleOpen);
        trigger.addEventListener("mouseleave", handleCloseWithDelay);
    
        // コンテンツのイベント
        content.addEventListener("mouseenter", clearHoverTimeout);
        content.addEventListener("mouseleave", handleCloseWithDelay);
    
        // フォーカスが外れた時
        element.addEventListener("focusout", (e) => {
          if (!element.contains(e.relatedTarget)) {
            clearHoverTimeout();
            if (isOpened(element)) {
              closeAccordion(element, content, true);
            }
          }
        });
      };
    
      // ===========================================
      // 開閉処理
      // ===========================================
    
      const toggleAccordion = (element, content) => {
        if (isOpened(element)) {
          closeAccordion(element, content);
        } else {
          openAccordion(element, content);
        }
      };
    
      const openAccordion = (element, content, force = false) => {
        if (force && isAnimating(element)) {
          cancelAnimation(element);
        }
        element.classList.add("is-opened");
        animateContent(content, 0, content.scrollHeight, element);
      };
    
      const closeAccordion = (element, content, force = false) => {
        if (force && isAnimating(element)) {
          cancelAnimation(element);
        }
        animateContent(content, content.scrollHeight, 0, element, () => {
          element.classList.remove("is-opened");
        });
      };
    
      // ===========================================
      // アニメーション
      // ===========================================
    
      const animateContent = (content, fromHeight, toHeight, element, onComplete) => {
        element.dataset.anim = "action";
    
        const animation = content.animate(
          [{ height: fromHeight + "px" }, { height: toHeight + "px" }],
          { duration: 400, easing: "ease-out", fill: "forwards" }
        );
    
        element._currentAnimation = animation;
    
        animation.onfinish = () => {
          element.dataset.anim = "";
          element._currentAnimation = null;
          content.style.height = toHeight === 0 ? "" : "auto";
          onComplete?.();
        };
    
        animation.oncancel = () => {
          element.dataset.anim = "";
          element._currentAnimation = null;
        };
      };
    
      const cancelAnimation = (element) => {
        if (element._currentAnimation) {
          element._currentAnimation.cancel();
          element._currentAnimation = null;
        }
      };
    
      // ===========================================
      // 実行
      // ===========================================
    
      if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", initAccordions);
      } else {
        initAccordions();
      }
    
    })();
    

    コード解説

    連打防止の仕組み

    クリック型では、アニメーション中に data-anim="action" を設定し、完了までクリックを無視します。

    if (isAnimating(element)) return;  // 連打防止
    

    ホバー型の遅延処理

    親リンクからサブメニューへマウスを移動する際、一瞬親の :hover が外れます。即座に閉じると操作しづらいため、100msの遅延を設けています。

    hoverTimeout = setTimeout(() => {
      if (isOpened(element)) {
        closeAccordion(element, content, true);
      }
    }, 100);
    

    サブメニューに入ったらタイマーをクリアすることで、閉じずに済みます。

    Web Animations API

    scrollHeight で実際のコンテンツ高さを取得し、element.animate() で滑らかにアニメーションします。

    content.animate(
      [{ height: fromHeight + "px" }, { height: toHeight + "px" }],
      { duration: 400, easing: "ease-out", fill: "forwards" }
    );
    

    開いた後は height: auto を設定することで、コンテンツの動的な変化にも対応します。

    :scope セレクタ

    直下の要素だけを確実に取得するため :scope を使用しています。

    element.querySelector(":scope > .sub-menu, :scope > .dion-content");
    

    補足:CSS-onlyで実現する方法

    grid-template-rows を使えば、JavaScript なしでもアニメーション付きの開閉が可能です。

    .hoverdion {
      display: grid;
      grid-template-rows: auto 0fr;  /* 1行目: リンク, 2行目: サブメニュー */
      transition: grid-template-rows 0.4s ease-out;
    }
    
    .hoverdion:hover,
    .hoverdion:focus-within {
      grid-template-rows: auto 1fr;
    }
    
    .hoverdion > .sub-menu {
      overflow: hidden;
    }
    

    ただし、この方法は構造が固定される(1行目がトリガー、2行目がコンテンツ)という制約があります。柔軟な構造が必要な場合は、JavaScript版を使用してください。

    このスクリプトはWordPressメニュー以外にも、dl/dt/dd などの汎用的な構造にも対応しているので、様々な場面で活用できます。