a-blog cms の Navigation モジュールを使ったドロップダウン「ボタン」対応


※この記事は a-blog cms Ver. 3.1.31 が最新のときに書いた記事です。
※Navigation モジュールを使ったドロップダウン「ボタン」対応については、CMS Ver. 3.2 以降にもっと実装が楽になりそうという内容になります。

このエントリーは「a-blog cms Advent Calendar 2024」の 3日目記事です。

a-blog cms の Navigation モジュール、みなさん使っていますか?
簡単に階層設定もできて便利ですよね。


siteテーマの Navigation モジュール管理画面

a-blog cms のほとんどの公式テーマのヘッダーナビゲーションでは、下階層が設定されている項目にマウスオーバーすると、ドロップダウンで下階層が表示される仕組みが用意されています。


UTSUWA テーマにおける、ヘッダーナビの下層メニューのドロップダウン表示例

公式テーマのヘッダーメニューを検証したところ、マウスオーバー時にドロップダウンするリンクとして設計されていることが分かります。

実際のコードをシンプルにした例:

<ul>
  <li>
    <a href="https://example.com/link1/">リンク</a>
  </li>
  <li>
    <a href="https://example.com/link2/">下階層のあるリンク</a>
    <ul>
      <li>
        <a href="https://example.com/link2-1/">下階層1</a>
      </li>
    </ul>
  </li>
</ul>

しかし、サイト制作の要件次第では、リンクではなく「ボタン」として実装しなければならない時が来るかもしれません。(実際には私はそのような要件を経験したことはありませんが……)

※ ここで言う「ボタン」とは、次の2種類を指します

  • <a> タグにボタンの役割を持たせた要素
  • <button> タグで記述された要素

将来そのような要件に迫られた場合を想定し、a-blog cms を使って Navigation モジュール内で「ドロップダウン対応のボタン化」が可能か試してみました。ボタン化くらいならHTMLを工夫して実現できるかと期待しましたが、結果として HTML だけでは対応できませんでした。

ということで、今回の記事では、Navigation モジュールでのドロップダウンボタン対応方法について考えたことと、たどり着いた実装方法、最後にこれからの a-blog cms ならやりたかった実装方法ができそうだという話をしていきます。

やりたかった実装方法

まず、Navigation モジュールでの「ドロップダウンボタン」にするアイテム条件を次のように定義しました

  • 下階層が設定されている
  • URL が設定されていない

この条件を HTML テンプレート内で記述し、Bootstrapa-blog cms 組み込みの JavaScript を活用することで、カスタム JavaScript の記述を最小限に抑える構成を目指しました。


Navigation モジュール設定画面:ドロップダウンボタンとして設定される要素の例


閲覧画面での Navigation モジュールのドロップダウンボタン表示例

ボタン化は HTML 内だけでは完結しなかった

HTML 内で IF文などを駆使しながらやればきっとボタン化できるはずだと Navigation モジュールの変数表を見てみると、次の制約により難しそうだということが分かりました。

  • {url} 変数はリンクの開始タグ内でのみ利用可能
  • アイテム自体が下階層を持つかを判定する方法が存在しない

Navigation モジュールの変数表

そうなると、「Navigationモジュールの設定画面で、ドロップダウンボタンにしたいリンクタグに必要な属性を付ければ良いのではないか」と思うかもしれません。
しかし、実装と管理はきちんと分離させてメンテナンス性を高めておきたいですし、意図しない変更を防ぐためにも、動作に関わるような実装部分を管理画面に持ち込むのは避けたいです。


Navigation モジュール設定画面:ドロップボタンにするために属性をたくさんつけるのは避けたい

最終的には JavaScript に任せることに

結果として、HTMLであれこれすることは諦め、ボタン化することも含めて JavaScript に任せることにしました。

デモは下記リンク画像から見ることができます。


Navigationモジュールを使った(想定)のドロップダウン「ボタン」化デモを開く


Navigation モジュール自体の記述はなるべくシンプルにします。


Navigation モジュールのHTMLテンプレート

HTML

<!-- BEGIN_MODULE Navigation id="nav_global" -->
<nav aria-label="メインメニュー">
  @include('/admin/module/setting.html')
  <div class="js-nav-dropdown c-nav-stack">
    <!-- BEGIN navigation:loop -->
    <!-- BEGIN ul#front -->
    <div class="js-navbar-outer">
      <ul>
        <!-- END ul#front -->
        <!-- BEGIN li#front -->
        <li {attr}>
          <!-- END li#front -->
          <!-- BEGIN link#front -->
          <a<!-- BEGIN_IF [{url}/nem] --> href="{url}"<!-- END_IF --><!-- BEGIN_IF [{target}/nem] --> target="{target}"<!-- END_IF --> {attr}>
            <!-- END link#front -->
            {label}[raw]
            <!-- BEGIN link#rear -->
            </a>
            <!-- END link#rear -->
            <!-- BEGIN li#rear -->
        </li>
        <!-- END li#rear -->
        <!-- BEGIN ul#rear -->
      </ul>
    </div>
    <!-- END ul#rear -->
    <!-- END navigation:loop -->
  </div>
</nav>
<!-- END_MODULE Navigation -->

※ ul を div で囲っているのはドロップダウンするアニメーションを CSS だけでやりたかったためです。

あとは、ナビゲーションのリストアイテムに下階層がいるかなどをチェックして、ボタン化していきます。


ドロップダウンのスタイル例

CSS

<!-- リセットCSS -->
<link rel="stylesheet" href="https://unpkg.com/tailwindcss@3.4.12/src/css/preflight.css" crossorigin="anonymous">

<style>
  /* ----------------------- */
  /* ナビゲーションバー
  /* ----------------------- */
  .container {
    margin: 0 auto;
    max-width: 500px;
    padding: 0 16px;
  }
  .header {
    margin-top: 80px;
  }
  .main {
    text-align: center;
    margin-block: 40px;
  }
  .footer {
    text-align: center;
    margin-top: 80px;
  }

  /* ----------------------- */
  /* ナビゲーションバー
  /* ----------------------- */
  .c-navbar {
    text-align: center;
  }

  .c-navbar li {
    font-size: 16px;
    text-align: left;
  }

  .c-navbar a,
  .c-navbar .js-nav-dropdown-trigger {
    display: flex;
    gap: 12px;
    align-items: center;
    justify-content: space-between;
    width: 100%;
    padding: 12px 16px;
    outline-offset: -2px;
    background-color: #FFF;
    transition: all 0.2s linear;
  }
  /* マウスオーバーした時 */
  .c-navbar a:hover,
  .c-navbar .js-nav-dropdown-trigger:hover {
    background-color: #F1F5F9;
  }

  /* 1階層目リンクとボタンの共通スタイル */
  .c-navbar > .js-navbar-outer > ul > li > a,
  .c-navbar .js-nav-dropdown-trigger {
    position: relative;
    z-index: 1;
    border-bottom: 4px solid #D8DBDE;
  }

  /* 1階層目
  /* ----------------------- */
  .c-navbar > .js-navbar-outer > ul {
    display: inline-flex;
    flex-wrap: wrap;
  }
  .c-navbar > .js-navbar-outer > ul > li {
    position: relative;
  }

  /* ドロップダウンボタン
  /* ----------------------- */
  .c-navbar .js-nav-dropdown-trigger::after {
    background-image: url('data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20height%3D%2220px%22%20viewBox%3D%220%20-960%20960%20960%22%20width%3D%2220px%22%20fill%3D%22undefined%22%3E%3Cpath%20d%3D%22m291-453-51-51%20240-240%20240%20240-51%2051-189-189-189%20189Z%22%2F%3E%3C%2Fsvg%3E');
    content: '';
    width: 16px;
    height: 16px;
    flex-shrink: 0;
    rotate: 180deg;
    transition: rotate 0.2s linear;
  }

  /* メニューが空いたとき */
  .c-navbar .js-nav-dropdown-point.is-hover .js-nav-dropdown-trigger::after,
  .c-navbar .js-nav-dropdown-trigger[aria-expanded='true']::after {
    rotate: 0deg;
  }

  /* ドロップダウン要素
  /* ----------------------- */
  .c-navbar .js-nav-dropdown-body {
    visibility: hidden;
    position: absolute;
    left: 0;
    min-width: max-content;
    display: grid !important; /* Bootstrapの上書き */
    grid-template-rows: 0fr;
    z-index: 2;
    inset: 100% 0 auto 0 !important; /* Bootstrapの上書き */
    transform: none !important; /* Bootstrapの上書き */
    transition: all 0.2s linear;
    box-shadow: 3px 3px 5px 0px rgba(0, 0, 0, 0.2);
  }

  .c-navbar .js-nav-dropdown-body {
    background-color: #f9fafb;
  }

  /* 2階層目 */
  .c-navbar .js-nav-dropdown-body > ul {
    overflow: hidden;
  }

  /* 3階層目以下 */
  .c-navbar .js-nav-dropdown-body ul ul a {
    padding-left: 10px;
  }

  /* メニューが空いたとき */
  .c-navbar .js-nav-dropdown-point.is-hover .js-nav-dropdown-body,
  .c-navbar .js-nav-dropdown-trigger[aria-expanded='true'] + .js-nav-dropdown-body {
    visibility: visible;
    grid-template-rows: 1fr;
  }
</style>

JavaScript によるドロップダウン「ボタン」対応例

JavaScript

<!-- 任意のドロップダウン用JS --> 
<script
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"
integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"
crossorigin="anonymous"></script>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.min.js"
integrity="sha384-fbbOQedDUMZZ5KreZpsbe1LCZPVmfTnH7ois6mU1QK+m14rQ1l2bGBq41eYeM/fS"
crossorigin="anonymous"></script>

<script>
  document.addEventListener('DOMContentLoaded', function () {
  /**
    * ナビゲーションリンクをドロップダウントリガーボタンに変更する
    */
  function convertLinkToButton(linkEle) {
    const buttonEle = document.createElement('button');

    // aタグの属性をbuttonタグにコピー
    Array.from(linkEle.attributes).forEach(function ({ name, value }) {
      if (name !== 'href' && name !== 'target' && name !== 'rel') {
        buttonEle.setAttribute(attr.name, attr.value);
      }
    });

    buttonEle.setAttribute('type', 'button');

    // aタグの中身をbuttonタグに移動
    while (linkEle.firstChild) {
      buttonEle.appendChild(linkEle.firstChild);
    }

    // aタグをbuttonタグに置き換え
    linkEle.parentNode.replaceChild(buttonEle, linkEle);

    return buttonEle;
  }

  /**
    * URLが一致するリンクに aria-current="page" を付与する
    */
  function setAriaCurrentForCurrentPage(container) {
    const currentUrl = window.location.href;
    container.querySelectorAll('a').forEach(function (link) {
      if (link.href === currentUrl) {
        link.setAttribute('aria-current', 'page');
      }
    });
  }

  /**
    * マウスホバーでのドロップダウン挙動を設定
    */
  // function setupHoverDropdown() {
  //   const dropdownItems = document.querySelectorAll('.js-nav-hover-dropdown .js-nav-dropdown-point');
  //   if (!dropdownItems.length) return;

  //   let timer;

  //   dropdownItems.forEach(function (item) {
  //     item.addEventListener('mouseover', function () {
  //       // 複数同時に開いた場合の対策
  //       dropdownItems.forEach(function (hoverItem) {
  //         hoverItem.classList.remove('is-hover');
  //       });

  //       item.classList.add('is-hover');
  //       clearTimeout(timer);
  //     });

  //     item.addEventListener('mouseout', function () {
  //       timer = setTimeout(function () {
  //         item.classList.remove('is-hover');
  //       }, 800);
  //     });

  //     // ホバーによる制御とクリック&タップによる制御を分離
  //     item.addEventListener('click', function () {
  //       item.classList.remove('is-hover');
  //     });
  //   });
  // }

  /**
    * メイン処理
    */
  const dropdownWraps = document.querySelectorAll('.js-nav-dropdown');

  if (!dropdownWraps.length) return;

  dropdownWraps.forEach(function (dropdownWrap, index1) {
    const navList = dropdownWrap.querySelector('ul');

    // モジュール内にul要素がない場合はスキップ
    if (!navList) return;

    // navList直下のli要素を取得
    navList.querySelectorAll(':scope > li').forEach(function (navItem, index2) {
      const linkEle = navItem.querySelector(':scope > a');

      // hrefが入っている場合はスキップ
      if (!linkEle || linkEle.getAttribute('href')) return;

      // 下層の要素を取得(下層の要素がない場合はスキップ)
      const dropdownBody = linkEle.nextElementSibling;
      if (!dropdownBody) return;

      const idName = `nav-dropdown-${index1}-${index2}`;
      navItem.classList.add('js-nav-dropdown-point');

      // ボタンに置き換え(aタグに適切な属性とイベントを付与すれば不要)
      const buttonEle = convertLinkToButton(linkEle);

      // ボタンの開閉に必要な属性設定
      buttonEle.setAttribute('aria-controls', idName);
      buttonEle.setAttribute('aria-expanded', 'false');

      // Bootstrapのドロップダウンを使うための属性設定
      buttonEle.setAttribute('data-bs-toggle', 'dropdown');

      // ドロップダウンボタンのクラス設定
      buttonEle.classList.add('js-nav-dropdown-trigger');

      // ドロップダウンする要素の設定
      dropdownBody.classList.add('js-nav-dropdown-body', 'dropdown-menu');
      dropdownBody.id = idName;
    });

    // URL一致リンクの処理
    setAriaCurrentForCurrentPage(dropdownWrap);
  });

  // ドロップダウン初期化
  dropdownWraps.forEach(function (dropdownWrap) {
    dropdownWrap.querySelectorAll('.js-nav-dropdown-trigger').forEach(function (trigger) {
      new bootstrap.Dropdown(trigger);
    });
  });

  // ホバーでのドロップダウン制御(hoverでも開いて欲しい時は読み込む)
  // setupHoverDropdown();
});
</script>

「ボタン」でありながらマウスオーバーでも開いて欲しいという要望もあるかもしれないのでマウスオーバー時の処理もコメントで入れてみましたが基本的には不要なはずです。

ドロップダウン「リンク」で済む場合は?

今回はボタンにするということを目的としたのでコードが長くなってしまいましたが、ボタンにする必要がない場合、UTSUWA テーマや Member テーマのヘッダーナビをそのまま使えば、十分な機能が得られるかと思います。
公式テーマ一覧 | テーマ | ドキュメント | a-blog cms developer

Navigation モジュールの Twig 対応を心待ちにしている

2025年春頃にリリース予定の新しい a-blog cms では Twig 対応が導入される予定です。
これにより、今回の課題であった以下の制約が解消される可能性があります。

  • {url} 変数の柔軟な利用
  • 下階層の有無をテンプレート内で判定可能

これらが実現すれば、HTML 内で条件に応じたボタン化ができるようになるはずです。

Ver. 3.2 β版 ではまだ Navigationモジュール は Twig 未対応なので、α版では対応されるのかなと期待しながらこの記事を終わろうと思います。
a-blog cms での Twig の書き方を体験してみよう! | 2024秋合宿 | ハンズオン | a-blog cms developer

明日の Advent Calendar は?

明日の 「a-blog cms Advent Calendar 2024」4日目は、川﨑さんです!


関連記事

HTML/CSSの基礎を一通り学ぶ

table印刷スタイルを調査してみた

Printスタイルで気をつけたい基本的なこと

CSS Grid レイアウトでハマったheightを0にしたpaddingでの高さ指定

アップルップル社内勉強会の覚書 JavaScript入門編

GAS で Slack に通知する

タグ