同じ見出しデータからPC・モバイルの目次を作る。JavaScriptの処理を共通化してみた

同じH2/H3データからPC用とモバイル用の目次UIを生成するJavaScript設計の図解

MANIARUのカテゴリー記事では、PCとスマートフォンで目次の見せ方を変えています。PCでは本文横のサイドバー、モバイルでは本文先頭の折りたたみ目次です。

表示場所と外側のUIは違いますが、案内するのは同じ記事のH2とH3です。現在の実装では、見出しを画面ごとに取り直さず、一度作ったheadingsを共通データとして使い、同じbuildList()から二つの目次DOMを生成しています。

この記事では、完成時の構造、JavaScriptの配置場所、必要なDOM、単一HTMLで試せる最小完成例、WordPressへ反映した後の確認方法まで順番に整理します。追従表示はCODE #01、現在位置のactive切り替えはCODE #02へ分け、今回は目次を作る処理だけを扱います。

完成するとどう動くか

記事本文に「概要」「準備」「実装」という見出しがあれば、PC用とモバイル用の目次にも同じ順序で三つのリンクが作られます。H3は直前のH2の下に入り、リンク先は元の見出しIDです。

  • PCでは、通常サイドバーの末尾にasideの目次を追加する
  • モバイルでは、本文の先頭にdetailsの折りたたみ目次を追加する
  • 二つの目次は、同じheadingsと同じ階層ルールから作る
  • 画面幅に応じた表示切り替えとPCのstickyはCSSへ任せる

このページ自体も同じ仕組みを使っています。PCでは右側、スマートフォンでは記事冒頭の「この記事の目次」を確認できます。

処理の全体像

  1. 記事本文とPCサイドバーを取得する
  2. 本文内のH2/H3を一つの配列へ入れる
  3. IDがない見出しへ連番のIDを付ける
  4. buildList()で目次リストを作る
  5. PC用のasideへリストを入れ、サイドバーへ追加する
  6. モバイル用のdetailsへ別のリストを入れ、本文先頭へ追加する

一つの目次DOMをPCからモバイルへ移動するのではありません。buildList()を2回呼び、同じ見出しデータから別々のDOMツリーを作ります。DOM要素は複数の親へ同時に置けないためです。

MANIARUではどこにJavaScriptを書いているか

現在のMANIARUでは、WordPress管理画面の「外観 → ウィジェット → サイドバー」にある「カスタムHTML」ブロックへ、カテゴリー目次の共通JavaScriptを置いています。

  1. WordPress管理画面で「外観」を開く
  2. 「ウィジェット」を開く
  3. 「サイドバー」を展開する
  4. サイドバー内の「カスタムHTML」を開く
  5. <script>内のカテゴリー目次用の自己実行関数を確認する

記事ごとにJavaScriptを貼る構成ではありません。AI / WEB / CODE / BUILD / GROW / MONEYの記事だけを対象に、記事のDOMが存在する状態で一度実行しています。

これは現在のMANIARUで確認できた配置場所です。テーマ、子テーマ、専用プラグインなどでJavaScriptを管理しているサイトもあります。カスタムHTMLへの配置をWordPress全体の標準手順とはせず、自分の環境で共通JavaScriptを管理している場所へ合わせます。

コードを読む前に前提DOMを確認する

要素・変数MANIARUでの役割
.p-entry__contentH2/H3を取得し、モバイル目次を先頭へ入れる記事本文
.l-sidebarPC用目次を末尾へ追加するサイドバー
headings本文から取得したH2/H3の配列
heading.id目次リンクの移動先。ない場合は連番を付ける
buildList()同じ見出し配列から新しい目次リストを返す関数
.maniaru-category-tocPC用目次の外側
.maniaru-category-mobileモバイル用目次の外側
<main class="p-entry">
  <div class="p-entry__content">
    <h2 id="section-1">概要</h2>
    <h3 id="section-1-1">準備</h3>
  </div>
</main>

<aside class="l-sidebar">
  <!-- PC用目次の生成先 -->
</aside>

モバイル用目次は別の固定枠を探すのではなく、.p-entry__contentの最初の子要素として挿入します。コード中のsidebarは突然現れる変数ではなく、.l-sidebarを取得した結果です。

単一HTMLファイルで最小完成例を試す

公開中のWordPressへ直接コードを加える前に、ローカルの単一HTMLファイルで中心処理を確認します。サーバーや追加ツールは必要ありません。

  1. テキストエディタで新しいファイルを作る
  2. ファイル名をresponsive-toc-demo.htmlにする
  3. 次の完成コードをすべてコピーして貼り付ける
  4. UTF-8で保存し、ブラウザで開く
  5. ブラウザ幅を広げた状態と狭めた状態で確認する

HTML、表示用CSS、目次を生成するJavaScriptを一つにまとめています。JavaScriptは<body>の末尾にあるため、本文と生成先が作られた後に実行されます。

<!doctype html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>PC・モバイル目次の共通化デモ</title>
  <style>
    * { box-sizing: border-box; }

    body {
      margin: 0;
      color: #1b2638;
      font-family: sans-serif;
    }

    .demo-layout {
      display: grid;
      grid-template-columns: minmax(0, 1fr) 280px;
      gap: 32px;
      max-width: 960px;
      margin: 0 auto;
      padding: 32px 20px;
    }

    .demo-content,
    .demo-sidebar,
    .demo-mobile {
      padding: 16px;
      border: 1px solid #e3e9f0;
    }

    .demo-toc-list ul {
      padding-left: 20px;
    }

    .demo-mobile {
      display: none;
      margin-bottom: 24px;
    }

    @media (max-width: 699px) {
      .demo-layout {
        display: block;
      }

      .demo-sidebar {
        display: none;
      }

      .demo-mobile {
        display: block;
      }
    }
  </style>
</head>
<body>
  <div class="demo-layout">
    <main class="demo-content">
      <h2 id="overview">概要</h2>
      <p>PCとモバイルで同じ見出しを使います。</p>

      <h3 id="preparation">準備</h3>
      <p>H3は直前のH2の下へ入ります。</p>

      <h2 id="implementation">実装</h2>
      <p>二つの目次を別々のDOMとして生成します。</p>
    </main>

    <aside class="demo-sidebar" aria-label="PC用目次の生成先"></aside>
  </div>

  <script>
(function () {
  var content = document.querySelector('.demo-content');
  var sidebar = document.querySelector('.demo-sidebar');
  if (!content || !sidebar) return;

  var headings = Array.prototype.slice.call(
    content.querySelectorAll('h2, h3')
  );
  if (!headings.length) return;

  headings.forEach(function (heading, index) {
    if (!heading.id) heading.id = 'demo-heading-' + (index + 1);
  });

  function buildList() {
    var root = document.createElement('ul');
    root.className = 'demo-toc-list';
    var currentH2 = null;
    var childList = null;

    headings.forEach(function (heading) {
      var item = document.createElement('li');
      var link = document.createElement('a');
      link.href = '#' + heading.id;
      link.textContent = heading.textContent.trim();
      item.appendChild(link);

      if (heading.tagName === 'H2') {
        root.appendChild(item);
        currentH2 = item;
        childList = null;
      } else if (currentH2) {
        if (!childList) {
          childList = document.createElement('ul');
          currentH2.appendChild(childList);
        }
        childList.appendChild(item);
      } else {
        root.appendChild(item);
      }
    });

    return root;
  }

  var desktopTitle = document.createElement('p');
  desktopTitle.textContent = 'PC用目次';
  sidebar.appendChild(desktopTitle);
  sidebar.appendChild(buildList());

  var mobile = document.createElement('details');
  mobile.className = 'demo-mobile';

  var summary = document.createElement('summary');
  summary.textContent = 'モバイル用目次';
  mobile.appendChild(summary);
  mobile.appendChild(buildList());

  content.insertBefore(mobile, content.firstChild);
})();
  </script>
</body>
</html>

最小例の成功状態を確認する

  • 700px以上では右側に「PC用目次」が表示される
  • 699px以下ではPC用目次が消え、本文先頭に「モバイル用目次」が表示される
  • どちらにも「概要 → 準備 → 実装」が同じ順序で並ぶ
  • 「準備」は「概要」の子リストへ入る
  • 各リンクを押すと、同じ名前の本文見出しへ移動する

ブラウザ幅を変えても見出しを再取得していません。ページを開いた時点でPC用とモバイル用の両方を生成し、CSSが画面幅に合う方だけを表示しています。

コードの重要部分を確認する

headingsを共通データにする

headingsには、本文内のH2/H3を上から順番に入れます。PC用とモバイル用で別々にquerySelectorAll()を実行しないため、対象見出しと順序がずれません。

buildListは呼ぶたびに新しいDOMを返す

buildList()は共通のheadingsを読みますが、呼ぶたびに新しいulを作ります。PC用のリストをモバイル側へ追加して移動させるのではなく、同じルールで二つ作ることが共通化の中心です。

IDとhrefを同じ値にする

目次リンクは#とheading.idを組み合わせます。IDがない見出しには先に連番を付けます。見出しIDが重複すると複数リンクが同じ場所を指すため、WordPressで手動アンカーを設定している場合も重複がないか確認します。

MANIARU本番の実装との違い

最小例は、目次生成の中心だけを確認するためのコードです。本番では、次の条件を追加しています。

  • CATEGORY記事だけを対象にする
  • PROJECT専用ナビがある記事では実行しない
  • 生成済みのモバイル目次やPROJECTナビ内の見出しを除外する
  • PC側は.l-sidebar、本文側は.p-entry__contentを使う
  • 本番用のclass名を付け、既存CSSへ接続する
  • 生成後にCODE #02のactive判定へ両方のリンクを渡す

目次DOMを作るJavaScriptと、どちらを表示するか決めるCSSは役割を分けています。PC用のsticky表示もCSSの役割です。現在位置の判定は目次生成後に行います。

WordPressへ反映して確認する

MANIARUの共通スクリプトを変更する場合は、カスタムHTMLの元コードを手元へ控えてから編集します。変更後はカスタムHTMLを更新し、公開記事を再読み込みします。

  1. PC幅で右側の目次が表示されることを確認する
  2. 本文のH2/H3と同じ項目が同じ順序で並ぶことを確認する
  3. H3が直前H2の子リストへ入ることを確認する
  4. 各リンクから対応する本文見出しへ移動できることを確認する
  5. スマートフォン幅でPC目次が消え、本文先頭の折りたたみ目次が表示されることを確認する
  6. 折りたたみ目次を開き、PCと同じ項目があることを確認する

この状態なら、見出し取得、IDとhrefの対応、共通リスト生成、PC・モバイルそれぞれへの挿入まで動いています。

目次が生成されないときに確認すること

次はMANIARUで起きた失敗談ではなく、同じ仕組みを別環境へ合わせるときの一般的な確認ポイントです。

  • 本文を取得できない:.demo-contentや.p-entry__contentが実際の本文要素と一致しているか
  • PC目次が出ない:sidebarが実際の生成先を取得できているか
  • リンクが移動しない:見出しIDとリンクのhrefが一致し、IDが重複していないか
  • H3の階層が違う:H3より前に親となるH2があるか
  • 何も生成されない:スクリプト実行時点で本文、サイドバー、見出しがDOMに存在するか
  • 目次項目が重複する:生成した目次内の見出しを再取得対象に含めていないか
  • 変更が見えない:ウィジェットを更新し、公開記事を再読み込みしたか

実装して分かったこと

共通化する対象は、完成した目次DOMそのものではなく、元になる見出しデータと組み立て方でした。同じDOMを使い回そうとすると表示先を移動してしまいますが、同じデータから別々に作れば、PCとモバイルの両方を同じページへ置けます。

データ取得、DOM生成、表示切り替え、sticky、active判定を分けると、どこを直す処理なのかも判断しやすくなりました。画面ごとに同じ処理を複製するのではなく、共通部分とUI固有部分の境界を決めることが重要でした。

関連記事