一部を開いた状態で対応できるdetailsとsummaryを使ったアコーディオンを実装してみる(備忘録)

今回は、一部を開いた状態で対応できるdetailsとsummaryを使ったアコーディオンを実装していきます。下記サイトも参考にさせていただきました。

detailsとsummaryタグで作るアコーディオンUI – アニメーションのより良い実装方法

自分の場合は、連打には対応させていません。実務向きではないかもしれませんが、ご紹介させていただきます。

対応していて苦労したのは、アコーディオン開閉時にSafariのアニメーションが効かないことです。試行錯誤していくうちになんとか解決しました。

それではやっていきましょう。

HTMLの記述について

<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>一部を開いた状態で対応できるdetailsとsammaryを使ったアコーディオンを実装してみる(備忘録)</title>
  <link rel="stylesheet" href="../reset.css">
  <link rel="stylesheet" href="./style.css">
</head>

<body>
  <main>
    <section>
      <div class="inner">
        <details class="js-details">
          <summary class="js-summary">
            項目が入ります項目が入ります項目が入ります
            <span class="icon"></span>
          </summary>
          <div class="js-content">
            <div class="js-content_inner">
              <p>項目の中身が入ります項目の中身が入ります項目の中身が入ります項目の中身が入ります項目の中身が入ります</p>
            </div>
          </div>
        </details>
        <details class="js-details" open>
          <summary class="js-summary">
            項目が入ります項目が入ります項目が入ります
            <span class="icon"></span>
          </summary>
          <div class="js-content">
            <div class="js-content_inner">
              <p>項目の中身が入ります項目の中身が入ります項目の中身が入ります項目の中身が入ります項目の中身が入ります</p>
            </div>
          </div>
        </details>
      </div>
    </section>
  </main>
  <script src="./style.js"></script>
</body>

</html>

detailsとsummaryを使うだけで、他のアプローチで対応するよりもアクセシビリティ対策が簡単にできるようなるそうです。詳しくはWHATWGをご覧ください。

また詳しい使い方はMDNをご覧ください。

アコーディオンを最初から展開したい場合は、detailsに対してopenをつけてください。

これでHTMLの準備は完了です。

CSSの記述について

@charset "utf-8";

/* ==========================
  初期設定
========================== */
*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  position: relative;
  word-wrap: break-word;
}

img {
  width: 100%;
  vertical-align: bottom;
}

/* レイアウト設定 */
.inner {
  width: min(800px, 100%);
  margin: 80px auto;
}

.js-details {
  border: 1px solid #ccc;
}

.js-summary::-webkit-details-marker {
  display: none;
}

.js-summary {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px 24px;
  background-color: #eee;
  font-weight: bold;
  cursor: pointer;
}

.js-summary .icon {
  position: relative;
  width: 24px;
  height: 24px;
  transition: transform 0.3s ease;
}

.js-summary .icon::before,
.js-summary .icon::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 16px;
  height: 2px;
  background-color: #222;
  transform: translate(-50%, -50%);
}

.js-summary .icon::after {
  transform: translate(-50%, -50%) rotate(90deg);
  transition: transform 0.3s ease;
}

.js-details[open] .js-summary .icon::after {
  transform: translate(-50%, -50%) rotate(0deg);
}

.js-content {
  background-color: #fff;
  overflow: hidden;
  transition: height 0.4s ease-out;
}

.js-content_inner {
  padding: 24px;
}

プラスアイコンを作るために、beforeやafterの疑似要素で線を作っています。アコーディオンが展開したときは縦線が横線と重なるように設定しています。

JavaScriptの記述について

"use strict";

document.addEventListener("DOMContentLoaded", () => {
  // ドキュメントが完全に読み込まれた後に実行される関数を設定
  const details = document.querySelectorAll(".js-details");
  // クラス名が "js-details" の全ての要素を取得

  details.forEach((detail, index) => {
    // 取得した要素をループで処理
    const summary = detail.querySelector(".js-summary");
    // 各要素内のクラス名が "js-summary" の要素を取得
    const content = detail.querySelector(".js-content");
    // 各要素内のクラス名が "js-content" の要素を取得
    const icon = summary.querySelector(".icon");
    // "summary" の中のクラス名が "icon" の要素を取得

    summary.addEventListener("click", (event) => {
      // "summary" がクリックされた時に実行される関数を設定
      event.preventDefault();

      const isOpen = detail.hasAttribute("open");
      // "detail" 要素が "open" 属性を持っているかどうかをチェック
      if (isOpen) {
        // もし開いている状態なら、閉じる処理を実行
        animateHeight(content, content.scrollHeight, 0, () => {
          detail.removeAttribute("open");
          // "open" 属性を削除
          icon.style.transform = "rotate(90deg)"; // アイコンをプラス記号に更新
          content.style.height = "0"; // 高さを0に設定して次回のためにリセット
        });
      } else {
        // 閉じている状態なら、開く処理を実行
        detail.setAttribute("open", "");
        // "open" 属性を追加
        icon.style.transform = "rotate(0deg)"; // アイコンをマイナス記号に更新
        content.style.height = "0px";
        // 開く前に高さを0に設定(前回の高さが'auto'であった場合のため)
        animateHeight(content, 0, content.scrollHeight, () => {
          content.style.height = "auto"; // 開き終わった後、高さを'auto'に設定
        });
      }
    });
  });
});

function animateHeight(element, startHeight, endHeight, callback) {
  // 高さのアニメーションを処理する関数
  element.style.height = `${startHeight}px`; // 開始高さを設定
  requestAnimationFrame(() => {
    element.style.height = `${endHeight}px`; // 終了高さに遷移開始
    element.addEventListener(
      "transitionend",
      () => {
        // 高さの変更が完了したらコールバック関数を実行
        if (callback) callback();
      },
      {
        once: true,
      }
    );
  });
}

コメントアウトにやっている詳細はコメントしています。

下記部分が個人的に難しいので深堀りしてみます。

function animateHeight(element, startHeight, endHeight, callback) {
  // 高さのアニメーションを処理する関数
  element.style.height = `${startHeight}px`; // 開始高さを設定
  requestAnimationFrame(() => {
    element.style.height = `${endHeight}px`; // 終了高さに遷移開始
    element.addEventListener(
      "transitionend",
      () => {
        // 高さの変更が完了したらコールバック関数を実行
        if (callback) callback();
      },
      {
        once: true,
      }
    );
  });
}

指定されたHTML要素の高さをアニメーションで変更するための関数になっており、処理の流れは下記のようになっています。

  1. 初期設定
    element.style.heightstartHeightに設定します。このステップにより、アニメーションの開始地点が確定します。
  2. アニメーションの開始
    requestAnimationFrame関数を使って、次の画面描画のタイミングで高さの変更を開始します。requestAnimationFrameはブラウザのリフレッシュレートに合わせて関数を呼び出すため、アニメーションがスムーズに行われます。
  3. 高さの変更
    内部のコールバック関数で、elementstyle.heightendHeightに変更します。この変更により、CSSのtransitionプロパティが適用され、指定した時間内でスムーズに高さが変わります。
  4. アニメーションの完了を検知
    transitionendイベントリスナーをelementに追加します。このイベントは、CSSの遷移が完了したときに発火します。{ once: true }オプションが指定されているため、イベントは一度だけ発火し、自動的にリスナーが解除されます。
  5. コールバックの実行
    transitionendイベントが発火したら、ユーザーが提供したコールバック関数(callback)が呼び出されます。これにより、高さの変更が完了した後に追加の処理を行うことができます。

アコーディオンってシンプルに対応できるかと思いきや、いろいろ考えることがあってコードも複雑でなかなか大変だと思いました。自分の対応したコードでも不十分な箇所もあると思いますが、とにかく実装ができたことはよかったと思っています。

アコーディオンに限らずですが、最近はベテランの方の厳しい声も結構目にしますので、より初学者や駆け出しの方にとって学習ハードルが高くなっている印象を感じています。

完璧を求めなくても、まずは作れたことに自信を持って前に進んでいくほうが気持ちが楽だと思います。できたときの嬉しいや楽しいの気持ちを大事にしながら、これからもいろいろ発信していけたらと思います。