PR

多分これが一番いいアコーディオンメニューだと思います。(高さ可変=height: auto; 対応)

JavaScriptを学ぶ

こんにちは。今回はアコーディオンメニューについて色々考えたので解説していこうと思います。

アコーディオンメニューと一言に言っても、googleで検索すると色々な実装パターンが出てきますよね。またライブラリもこれといったものがあまりありません。

そこで僕なりにこれがいいんじゃないかというアコーディオンメニューの実装を考えてみたので公開します。前提の話を色々していますが、実装だけ気になるという方は「結論」セクションと「実装」セクションだけご覧いただければ十分かと思います。

コードとしては改善点もたくさんあると思いますので、有識者の方はぜひフィードバックをお願いします。

結論

デモサイトはこちら:https://itokoba.com/demos/accordion-menu/

コードを以下に載せます。矢印の画像はお好みで、ないしは上記デモサイトからダウンロードしてご用意ください。また各CSSファイルはstylesディレクトリに入れている想定です。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <link rel="stylesheet" href="./styles/destyle.css" />
    <link rel="stylesheet" href="./styles/style.css" />
  </head>
  <body>
    <div class="Section">
      <details class="Accordion">
        <summary class="Accordion-Title">アコーディオン1</summary>
        <p class="Accordion-Content"
          >アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。</p
        >
      </details>
      <details class="Accordion">
        <summary class="Accordion-Title">
          <span class="Accordion-Title-Text">アコーディオン2</span>
          <img src="./images/arrow.svg" alt="" class="Accordion-Title-Icon" />
        </summary>
        <p class="Accordion-Content"
          >アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。</p
        >
      </details>
    </div>
    <script>
      class Accordion {
        static #OPENED = "opened"
        static #OPENING = "opening"
        static #CLOSED = "closed"
        static #CLOSING = "closing"

        #details
        #summary
        #content
        #duration = 300
        #durationName = "--accordion-duration"
        #timingFunction = "ease"
        #timingFunctionName = "--accordion-timing-function"
        #delay = 0
        #delayName = "--accordion-delay"
        #activeClass = "AccordionIsOpen"
        #_status

        constructor(options) {
          if (!options.target) throw new Error("'target' must need Element.")

          this.#details = options.target
          this.#summary = this.#details.querySelector("summary")
          this.#content = this.#details.querySelector("summary + *")
          this.#status = options.isOpenOnDefault
            ? Accordion.#OPENED
            : Accordion.#CLOSED

          this.#duration = options.duration ?? this.#duration
          this.#durationName = options.durationName ?? "--accordion-duration"
          this.#details.style.setProperty(
            this.#durationName,
            this.#duration + "ms"
          )

          this.#timingFunction = options.timingFunction ?? this.#timingFunction

          this.#timingFunctionName =
            options.timingFunctionName ?? "--accordion-timing-function"
          this.#details.style.setProperty(
            this.#timingFunctionName,
            this.#timingFunction
          )

          this.#delay = options.delay ?? this.#delay
          this.#delayName = options.delayName ?? "--accordion-delay"
          this.#details.style.setProperty(this.#delayName, this.#delay)

          this.#details.style.transition = `height ${this.#duration}ms ${
            this.#timingFunction
          } ${this.#delay}ms`
          this.#details.style.overflow = "hidden"

          this.#activeClass = options.activeClass ?? this.#activeClass

          this.#summary.addEventListener("click", (event) =>
            this.#toggle(event)
          )

          this.#details.addEventListener("transitionend", () =>
            this.#onTransitionEnd()
          )
        }

        get #status() {
          return this.#_status
        }

        set #status(status) {
          this.#_status = status

          switch (status) {
            case Accordion.#OPENED:
              this.#details.setAttribute("open", "")
              this.#details.classList.add(this.#activeClass)
              this.#details.style.height = "auto"
              break

            case Accordion.#OPENING:
              this.#details.setAttribute("open", "")
              this.#details.classList.add(this.#activeClass)
              this.#details.style.height = this.#openingHeight
              break

            case Accordion.#CLOSED:
              this.#details.removeAttribute("open")
              this.#details.classList.remove(this.#activeClass)
              this.#details.style.height = "auto"
              break

            case Accordion.#CLOSING:
              this.#details.setAttribute("open", "")
              this.#details.classList.remove(this.#activeClass)
              this.#details.style.height = this.#closingHeight
              break
          }
        }
        get #openingHeight() {
          return (
            this.#summary.getBoundingClientRect().height +
            this.#content.getBoundingClientRect().height +
            this.#getVerticalBorderWidth() +
            "px"
          )
        }
        get #closingHeight() {
          return (
            this.#summary.getBoundingClientRect().height +
            this.#getVerticalBorderWidth() +
            "px"
          )
        }

        #getVerticalBorderWidth() {
          const computedStyle = getComputedStyle(this.#details)
          const borderTopWidth = parseInt(
            computedStyle.getPropertyValue("border-top-width")
          )
          const borderBottomWidth = parseInt(
            computedStyle.getPropertyValue("border-bottom-width")
          )
          return borderTopWidth + borderBottomWidth
        }

        #toggle(event) {
          event.preventDefault()

          switch (this.#status) {
            case Accordion.#OPENED:
              this.#details.style.height = this.#openingHeight
              setTimeout(() => {
                this.#status = Accordion.#CLOSING
              }, 10)
              break

            case Accordion.#OPENING:
              this.#status = Accordion.#CLOSING
              break

            case Accordion.#CLOSED:
              this.#details.style.height = this.#closingHeight
              setTimeout(() => {
                this.#status = Accordion.#OPENING
              }, 10)

              break

            case Accordion.#CLOSING:
              this.#status = Accordion.#OPENING
              break
          }
        }

        #onTransitionEnd() {
          if (this.#status === Accordion.#CLOSING) {
            this.#status = Accordion.#CLOSED
            return
          }

          if (this.#status === Accordion.#OPENING) {
            this.#status = Accordion.#OPENED
            return
          }
        }
      }

      document.querySelectorAll(".Accordion").forEach((element, index) => {
        new Accordion({
          target: element,
          duration: 400,
          easing: "ease",
          isOpenOnDefault: index === 0,
        })
      })
    </script>
  </body>
</html>
.Accordion {
  border: 1px solid #aaa;
  width: 100%;
}
.Accordion-Title {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px;
  background: #f7f7f7;
  cursor: pointer;
}
.Accordion-Title-Icon {
  width: 20px;
  transition-property: transform;
  transition-duration: var(--accordion-duration);
  transition-timing-function: var(--accordion-timing-function);
  transition-delay: var(--accordion-delay);
}
.AccordionIsOpen .Accordion-Title-Icon {
  transform: rotateZ(-180deg);
}
.Accordion-Title::-webkit-details-marker {
  display: none;
}
.Accordion-Content {
  padding: 1em;
}
/*! destyle.css v3.0.2 | MIT License | https://github.com/nicolas-cusan/destyle.css */

/* Reset box-model and set borders */
/* ============================================ */

*,
::before,
::after {
  box-sizing: border-box;
  border-style: solid;
  border-width: 0;
}

/* Document */
/* ============================================ */

/**
 * 1. Correct the line height in all browsers.
 * 2. Prevent adjustments of font size after orientation changes in iOS.
 * 3. Remove gray overlay on links for iOS.
 */

html {
  line-height: 1.15; /* 1 */
  -webkit-text-size-adjust: 100%; /* 2 */
  -webkit-tap-highlight-color: transparent; /* 3*/
}

/* Sections */
/* ============================================ */

/**
 * Remove the margin in all browsers.
 */

body {
  margin: 0;
}

/**
 * Render the `main` element consistently in IE.
 */

main {
  display: block;
}

/* Vertical rhythm */
/* ============================================ */

p,
table,
blockquote,
address,
pre,
iframe,
form,
figure,
dl {
  margin: 0;
}

/* Headings */
/* ============================================ */

h1,
h2,
h3,
h4,
h5,
h6 {
  font-size: inherit;
  font-weight: inherit;
  margin: 0;
}

/* Lists (enumeration) */
/* ============================================ */

ul,
ol {
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Lists (definition) */
/* ============================================ */

dt {
  font-weight: bold;
}

dd {
  margin-left: 0;
}

/* Grouping content */
/* ============================================ */

/**
 * 1. Add the correct box sizing in Firefox.
 * 2. Show the overflow in Edge and IE.
 */

hr {
  box-sizing: content-box; /* 1 */
  height: 0; /* 1 */
  overflow: visible; /* 2 */
  border-top-width: 1px;
  margin: 0;
  clear: both;
  color: inherit;
}

/**
 * 1. Correct the inheritance and scaling of font size in all browsers.
 * 2. Correct the odd `em` font sizing in all browsers.
 */

pre {
  font-family: monospace, monospace; /* 1 */
  font-size: inherit; /* 2 */
}

address {
  font-style: inherit;
}

/* Text-level semantics */
/* ============================================ */

/**
 * Remove the gray background on active links in IE 10.
 */

a {
  background-color: transparent;
  text-decoration: none;
  color: inherit;
}

/**
 * 1. Remove the bottom border in Chrome 57-
 * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
 */

abbr[title] {
  text-decoration: underline dotted; /* 2 */
}

/**
 * Add the correct font weight in Chrome, Edge, and Safari.
 */

b,
strong {
  font-weight: bolder;
}

/**
 * 1. Correct the inheritance and scaling of font size in all browsers.
 * 2. Correct the odd `em` font sizing in all browsers.
 */

code,
kbd,
samp {
  font-family: monospace, monospace; /* 1 */
  font-size: inherit; /* 2 */
}

/**
 * Add the correct font size in all browsers.
 */

small {
  font-size: 80%;
}

/**
 * Prevent `sub` and `sup` elements from affecting the line height in
 * all browsers.
 */

sub,
sup {
  font-size: 75%;
  line-height: 0;
  position: relative;
  vertical-align: baseline;
}

sub {
  bottom: -0.25em;
}

sup {
  top: -0.5em;
}

/* Replaced content */
/* ============================================ */

/**
 * Prevent vertical alignment issues.
 */

svg,
img,
embed,
object,
iframe {
  vertical-align: bottom;
}

/* Forms */
/* ============================================ */

/**
 * Reset form fields to make them styleable.
 * 1. Make form elements stylable across systems iOS especially.
 * 2. Inherit text-transform from parent.
 */

button,
input,
optgroup,
select,
textarea {
  -webkit-appearance: none; /* 1 */
  appearance: none;
  vertical-align: middle;
  color: inherit;
  font: inherit;
  background: transparent;
  padding: 0;
  margin: 0;
  border-radius: 0;
  text-align: inherit;
  text-transform: inherit; /* 2 */
}

/**
 * Reset radio and checkbox appearance to preserve their look in iOS.
 */

[type="checkbox"] {
  -webkit-appearance: checkbox;
  appearance: checkbox;
}

[type="radio"] {
  -webkit-appearance: radio;
  appearance: radio;
}

/**
 * Correct cursors for clickable elements.
 */

button,
[type="button"],
[type="reset"],
[type="submit"] {
  cursor: pointer;
}

button:disabled,
[type="button"]:disabled,
[type="reset"]:disabled,
[type="submit"]:disabled {
  cursor: default;
}

/**
 * Improve outlines for Firefox and unify style with input elements & buttons.
 */

:-moz-focusring {
  outline: auto;
}

select:disabled {
  opacity: inherit;
}

/**
 * Remove padding
 */

option {
  padding: 0;
}

/**
 * Reset to invisible
 */

fieldset {
  margin: 0;
  padding: 0;
  min-width: 0;
}

legend {
  padding: 0;
}

/**
 * Add the correct vertical alignment in Chrome, Firefox, and Opera.
 */

progress {
  vertical-align: baseline;
}

/**
 * Remove the default vertical scrollbar in IE 10+.
 */

textarea {
  overflow: auto;
}

/**
 * Correct the cursor style of increment and decrement buttons in Chrome.
 */

[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
  height: auto;
}

/**
 * 1. Correct the outline style in Safari.
 */

[type="search"] {
  outline-offset: -2px; /* 1 */
}

/**
 * Remove the inner padding in Chrome and Safari on macOS.
 */

[type="search"]::-webkit-search-decoration {
  -webkit-appearance: none;
}

/**
 * 1. Correct the inability to style clickable types in iOS and Safari.
 * 2. Fix font inheritance.
 */

::-webkit-file-upload-button {
  -webkit-appearance: button; /* 1 */
  font: inherit; /* 2 */
}

/**
 * Clickable labels
 */

label[for] {
  cursor: pointer;
}

/* Interactive */
/* ============================================ */

/*
 * Add the correct display in Edge, IE 10+, and Firefox.
 */

details {
  display: block;
}

/*
 * Add the correct display in all browsers.
 */

summary {
  display: list-item;
}

/*
 * Remove outline for editable content.
 */

[contenteditable]:focus {
  outline: auto;
}

/* Tables */
/* ============================================ */

/**
1. Correct table border color inheritance in all Chrome and Safari.
*/

table {
  border-color: inherit; /* 1 */
  border-collapse: collapse;
}

caption {
  text-align: left;
}

td,
th {
  vertical-align: top;
  padding: 0;
}

th {
  text-align: left;
  font-weight: bold;
}

githubでも公開していますので、そちらからもご覧いただけます。

GitHub - Hideki-Kobayashi-Eclair/accordion-menu
Contribute to Hideki-Kobayashi-Eclair/accordion-menu development by creating an account on GitHub.

序文

まず、アコーディオンメニューの動きを見るとそれほど難しくないように思われます。

実際簡単に作るなら、コンテンツ部分の要素のheightを0と特定の数値の間でtransitionさせるだけです。

じゃあheight: auto;の時もそれでいいのでは?という話なのですが、残念ながらautoが絡むとtransitionが起こりません。

アコーディオンメニュー自体はやはりheight: auto;になっているのが望ましいでしょう、デバイスやブラウザ横幅によってコンテンツの高さは基本的に変わりますからね。

そこで過去にheight: auto;に対応したバージョンの解説をしました。以下のような工夫をしています。

一旦数値を挟んでからアニメーションさせ、その後に高さをautoにするという手順を踏みましょう。

具体的には、メニューを開くときは

0→(アニメーション)→コンテンツの高さ→auto

メニューを閉じるときは

auto→コンテンツの高さ→(アニメーション)→0

というような変化をするようにしていきます。

今回解説する内容はやや複雑になるので、難しく感じた方は一旦こちらの記事のパターンを使ってみてもよいかもしれません。

では今回なぜ改めて実装を考えたのかというと、details/summaryタグの登場です。

: 詳細折りたたみ要素 - HTML: ハイパーテキストマークアップ言語 | MDN
は HTML の要素で、ウィジェットが「開いた」状態になった時のみ情報が表示される折りたたみウィジェットを作成します。概要やラベルは 要素を使用して提供する必要があります。

登場と言ってもHTML5の時点で追加されていますからそれほど最近でもないのですが、せっかく統一された規格があるわけですから使ってみようということです。

どんな仕様になるかを考える

details/summaryタグを用いる

まず、前述の通りdetails/summaryタグを用います。detailsタグは標準で開閉機能がついています。開いているときはopen属性を付与し、閉じているときは外すという動きになっています。

ただしこの機能をそのまま使った場合は残念ながらtransitionを指定しても適用されません。実装ではpreventDefault()を使って無効にします。ただしopen属性の付け外しはする必要がありますから、その動きは実装に取り入れます。

メニューの開閉はdetailsタグの高さを変えることで実現する

detailsタグの高さが、

  • メニューが閉じている時はsummaryタグ部分の高さ(画像1)
  • メニューが開いている時はsummaryタグ部分 + コンテンツ部分の高さ(画像2)

となるようにします。

画像1
画像2

メニューが開いているとき、あるいは閉じているときはheight: auto; にし、かつtransitionが動くようにする

アコーディオンメニューの各要素(summary、コンテンツ部分)は、当然横幅によって改行が発生したりします。ですから可能であれば高さは可変(= height: auto;)の方が都合がいいですね。しかし前述の通り単純にtransitionの指定をすればいいわけではないので注意が必要です。

開閉の途中でもクリックされたら反応する

例えば開いている途中でも、クリックすれば閉じるようにします。逆も然りです。開き切った/閉じ切った状態の時のみ反応する形でも良かったのですが、一応反応した方が汎用的かなと思いそのようにしています。

実装

見た目を作る

まずテンプレート部分をサクッと作ってみましょう。ひとまずシンプルなパターンのものを1つ用意します。この時点でメニューの開閉が可能です。

アコーディオン1

アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。

<details class="Accordion">
  <summary class="Accordion-Title">アコーディオン1</summary>
  <p class="Accordion-Content"
    >アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。</p
  >
</details>
.Accordion {
  border: 1px solid #aaa;
  width: 100%;
}

.Accordion-Title {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px;
  background: #f7f7f7;
  cursor: pointer;
}

.Accordion-Title::-webkit-details-marker {
  display: none;
}

.Accordion-Content {
  padding: 1em;
}

ポイントは次の2点です。

  1. summaryタグはdisplayにlist-item以外の何らかの値を指定する
  2. summaryタグの擬似要素、::-webkit-details-markerに対してdisplay: none;を指定する
  3. コンテンツ部分は1つの要素にする

1の指定を行うことで、details/summaryの標準の黒い三角マークを非表示にします。

2はそれをiOSのSafariでも非表示にするものです。

また、実装の都合上コンテンツ部分は必ず1つの要素でまとめてください。中はどんな構造でもOKです。

動きをつける

アコーディオンメニューのスクリプトはライブラリのように使いまわせる形がいいなと思ったので、今回はクラスを用いて実装します。

状態を整理する

まず、改めて最初に決めた仕様を確認します。

  1. details/summaryタグを用いる
  2. メニューの開閉はdetailsタグの高さを変えることで実現する
  3. メニューが開いているとき、あるいは閉じているときはheight: auto; にし、かつtransitionが動くようにする
  4. 開閉の途中でもクリックされたら反応する

ここから、変化する値は何なのかを考えます。

  • detailsタグのheight(開閉の動きを表現する)
  • detailsタグのopen属性(details/summaryタグの標準の動きに従う)

結局のところ、変化するのはこの2つだけです。

さて、ここからがちょっと複雑です。最初に決めた仕様に従いつつ上記2つの値がどう変化するかを整理します。

開閉をさせたいわけですから、「開いている時」「閉じている時」というタイミングは確実に存在しますね。

また、今回は「開閉の途中でもクリックされたら反応する」ようにしたいです。開いている途中にクリックされたら閉じて、閉じている途中にクリックされたら開きます。よって「開いている途中」と「閉じている途中」という2つのタイミングもありそうです。

よって以下の4つのタイミングについて上記2つの値を考えます。

  • 開いている時
  • 開いている途中
  • 閉じている時
  • 閉じている途中

detailsタグは、open属性がない時は高さに関わらずコンテンツ部分が非表示になります。

このように、コンテンツを表示するのに十分な高さがあっても表示されない

よって完全に閉じている時以外はopen属性が必要と考えられます。

detailsの高さは、「summaryタグ部分の高さ」と「summaryタグ部分の高さ + コンテンツ部分の高さ」を行き来することになります。

閉じている時
開いている時

transitionの動き的には、

  • 開く動き:summaryタグ部分の高さ → summaryタグ部分の高さ + コンテンツ部分の高さ
  • 閉じる動き:summaryタグ部分の高さ + コンテンツ部分の高さ → summaryタグ部分の高さ

と表現しますね。

ただし仕様のところでも言った通り、summary部分ないしはコンテンツ部分の内容がブラウザ/デバイス幅によって高さは変わります。よって完全に開いている時/閉じている時のheightはauto;が望ましいですね。

ここまでをまとめると、以下の通りになります。

detailsのheightdetailsのopen属性
開いている時autoあり
開いている途中summaryタグ部分の高さ + コンテンツ部分の高さあり
閉じている時autoなし
閉じている途中summaryタグ部分の高さあり

整理した状態を元にクラスを作る

使い方のイメージは以下の形です。

document.querySelectorAll(".Accordion").forEach((element, index) => {
  new Accordion({
    target: element,
  })
})
プロパティを定義する

まずは必要な要素と状態に関するプロパティを定義します。

基本的に各メソッド、プロパティは外部から呼び出す想定がないのでprivateにしていますが、間違っていたらコメントお願いします。

参考:https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Classes/Private_class_fields

<script>
  class Accordion {
    static #OPENED = "opened" //開いている時
    static #OPENING = "opening" //開いている途中
    static #CLOSED = "closed" //閉じている時
    static #CLOSING = "closing" //閉じている途中

    #details
    #summary
    #content
    #_status
  }
</script>

前セクションできめた4つの状態をそれぞれopened/opening/closed/closingと命名します。プロパティに格納した方が都合が良いのでそうしています。(細かい話ですが静的なプロパティにしています。参考:https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Classes/static

そして、details要素、summary要素、コンテンツ要素を格納するプロパティと4つの状態を管理するプロパティを定義します。#_statusがアンダースコア始まりな理由は後述します。

プロパティの初期化を定義する

次に各プロパティの初期化です。

<script>
  class Accordion {
    static #OPENED = "opened"
    static #OPENING = "opening"
    static #CLOSED = "closed"
    static #CLOSING = "closing"

    #details
    #summary
    #content
    #_status

    // ここから
    constructor(options) {
      if (!options.target) throw new Error("'target' must need Element.")

      this.#details = options.target
      this.#summary = this.#details.querySelector("summary")
      this.#content = this.#details.querySelector("summary + *")
      this.#status = Accordion.#CLOSED
    }
    // ここまで追加
  }
</script>

クラスの初期化時に要素を渡す形で利用する想定です。一般的なライブラリだと対象の要素はtargetという名前で渡すことが多い気がするのでそれに倣ってみました。

この時点では#statusはまだ存在していませんが、これでOKです。

また、一応targetがなかった時にエラーを出すようにしています。

各状態の定義を行う

次に、4つの状態のそれぞれについて定義します。

<script>
  class Accordion {
    //省略

    constructor(options) {
      //省略
    }

    //ここから
    get #status() {
      return this.#_status
    }

    set #status(status) {
      this.#_status = status

      switch (status) {
        case Accordion.#OPENED:
          this.#details.setAttribute("open", "") //open属性あり
          this.#details.style.height = "auto"
          break

        case Accordion.#OPENING:
          this.#details.setAttribute("open", "") //open属性あり
          this.#details.style.height = this.#openingHeight //下で定義する
          break

        case Accordion.#CLOSED:
          this.#details.removeAttribute("open") //open属性なし
          this.#details.style.height = "auto"
          break

        case Accordion.#CLOSING:
          this.#details.setAttribute("open", "") //open属性あり
          this.#details.style.height = this.#closingHeight //下で定義する
          break
      }
    }
    get #openingHeight() {
      return (
        this.#summary.getBoundingClientRect().height + //summaryタグの高さ
        this.#content.getBoundingClientRect().height + //コンテンツ部分の高さ
        "px"
      )
    }
    get #closingHeight() {
      return this.#summary.getBoundingClientRect().height + "px" //summaryタグの高さ
    }
    //ここまで追加
  }
</script>

setterを使うことで#statusに値を代入する際に何らかの処理も行うことができます(参考:https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Functions/set)。

getter/setterを使う場合は名前の被りを回避する必要があるため、プロパティ名としては#_statusとアンダースコア始まりにしていました。そしてここで#statusを定義したことでコンストラクターでの初期化時にはこれが呼び出されることになります。

内容を見ていくと、前セクションで整理したそれぞれの状態を表現しています。

開いている途中の高さ(= openingHeight)と閉じている途中の高さ(= closingHeight)もそれぞれ計算したものを定義しています。

ちなみに高さはoffsetHeightでも良いと言えばいいのですが、そちらは値が必ず整数になります。getBoundingClientRect().heightは小数点以下も含めて厳密な値を取得できるので、今回はこちらを使いました。

transitionなど、初期のスタイリング設定を行う

detailsに対してアコーディオンメニューとして必要なスタイリングを付与します。コンストラクター内に処理を追加します。

<script>
  class Accordion {
    //省略

    constructor(options) {
      if (!options.target) throw new Error("'target' must need Element.")

      this.#details = options.target
      this.#summary = this.#details.querySelector("summary")
      this.#content = this.#details.querySelector("summary + *")
      this.#status = Accordion.#CLOSED

      this.#details.style.transition = "height 300ms ease 0s" //追加
      this.#details.style.overflow = "hidden" //追加
    }

    //省略
  }
</script>

transitionの指定はCSSプロパティでの書き方に従います。またoverflow: hidden; がないと閉じる途中にコンテンツ部分がはみ出して見えます。

クリック時にメニューが開閉するようにする

summaryタグにクリック時の処理を追加したいので、まずはイベントリスナーを定義します。これもコンストラクター内で行います。

<script>
  class Accordion {
    //省略

    constructor(options) {
      //省略

      this.#details.style.transition = "height 300ms ease 0s" 
      this.#details.style.overflow = "hidden" 

      //ここから追加
      this.#summary.addEventListener("click", (event) =>
        this.#toggle(event)
      )
    }

    //省略
  }
</script>

クリック時に実行したいメソッドは#toggleとします。

次にその#toggleの中身を定義します。やりたいことはクリック時に開閉することで、具体的には#statusの変更です。

アコーディオンメニューにおいて#statusがどのように変化するかというと、次の表のようになります。

クリックした時の変化先
CLOSED(閉じている時)OPENING
CLOSING(閉じている途中)OPENING
OPENED(開いている時)CLOSING
OPENING(開いている途中)CLOSING

クリック直後は必ずtransitionが動いているはずですから、OPENINGもしくはCLOSINGのどちらかにになります。

これを#toggleメソッドで表現します。

<script>
  class Accordion {
    //省略

    constructor(options) {
      //省略
    }

    //省略

    get #closingHeight() {
      return this.#summary.getBoundingClientRect().height + "px"
    }

    //ここから追加
    #toggle(event) {
      event.preventDefault() // デフォルトの開閉機能を無効にする

      switch (this.#status) {
        case Accordion.#OPENED:
          this.#details.style.height = this.#openingHeight // 一旦固定の値にする
          setTimeout(() => {
            this.#status = Accordion.#CLOSING // 一瞬遅らせて#statusを変える
          }, 10)
          break

        case Accordion.#OPENING:
          this.#status = Accordion.#CLOSING
          break

        case Accordion.#CLOSED:
          this.#details.style.height = this.#closingHeight // 一旦固定の値にする
          setTimeout(() => {
            this.#status = Accordion.#OPENING // 一瞬遅らせて#statusを変える
          }, 10)

          break

        case Accordion.#CLOSING:
          this.#status = Accordion.#OPENING
          break
      }
    }
  }
</script>

detailsのデフォルトの開閉機能を無効にするため、最初にevent.preventDefault()を実行します。

OPENING/CLOSINGの時は単純に#statusを変更するだけですが、OPENED/CLOSEDの時は一手間必要です。OPENED/CLOSEDの時はheightはautoの想定で、このままではtransitionが動きません。

そこでheightを一旦固定の値にしてから一瞬遅らせて#statusの変更を行います。こうすることでtransitionが適用されます。

ここまでの処理で開閉の動きはできていますが、最後に開閉のアニメーションが終わったタイミングで#statusをOPENED/CLOSEDに変更する処理を加えます。今の段階ではまだOPENING/CLOSINGの状態で処理が終わってしまっています。

<script>
  class Accordion {
    //省略

    constructor(options) {
      //省略

      this.#summary.addEventListener("click", (event) =>
        this.#toggle(event)
      )
     
     // ここから
   this.#details.addEventListener("transitionend", () =>
       this.#onTransitionEnd()
     )
     //ここまで追加
    }

    //省略

    #toggle(event) {
      //省略
    }

    //ここから
    #onTransitionEnd() {
      if (this.#status === Accordion.#CLOSING) { 
        this.#status = Accordion.#CLOSED
        return
      }

      if (this.#status === Accordion.#OPENING) {
        this.#status = Accordion.#OPENED
        return
      }
    }
    //ここまで追加
  }
</script>

各要素にはtransitionendイベントが存在しており、transitionの完了をキャッチすることができます。このタイミングでCLOSINGだったらCLOSEDに、OPENINGだったらOPENEDに変更します。

微調整を行う

ここまでの実装で最低限の動きはほとんど完成なのですが、動かしてみると微妙にズレがあるのがわかるかと思います。

原因はdetailsのborderで、height: auto;の時のdetailsの高さは厳密には子要素の高さ + 縦方向のborder分になります。openHeightとcloseHeightにその分を追加しましょう。

<script>
  class Accordion {
    //省略

    constructor(options) {
      //省略
    }
    //省略

    get #openingHeight() {
      return (
        this.#summary.getBoundingClientRect().height +
        this.#content.getBoundingClientRect().height +
        this.#getVerticalBorderWidth() + //追加
        "px"
      )
    }
    get #closingHeight() {
      return (
        this.#summary.getBoundingClientRect().height +
        this.#getVerticalBorderWidth() + //追加
        "px"
      )
    }

    //ここから追加
    #getVerticalBorderWidth() {
      const computedStyle = getComputedStyle(this.#details)
      const borderTopWidth = parseInt(
        computedStyle.getPropertyValue("border-top-width")
      )
      const borderBottomWidth = parseInt(
        computedStyle.getPropertyValue("border-bottom-width")
      )
      return borderTopWidth + borderBottomWidth
    }
    //省略
  }

  document.querySelectorAll(".Accordion").forEach((element, index) => {
    new Accordion({
      target: element,
    })
  })
</script>

detailsの最終的なスタイルを取得(getComputedStyle)し、その中からborder-top-widthとborder-bottom-widthの値を取得します。そのままだと単位付き(ex. 1px)なのでparseIntで数値にします。

完成

これで最低限のアコーディオンメニューが完成です!

使う時はクラスの初期化時に対象の要素を渡してください。大抵メニューにしたい要素は複数あるでしょうからこのような形です。

document.querySelectorAll(".Accordion").forEach((element, index) => {
  new Accordion({
    target: element,
  })
})

ここまでのコード:

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <link rel="stylesheet" href="./styles/destyle.css" />
    <link rel="stylesheet" href="./styles/style.css" />
  </head>
  <body>
    <div class="Section">
      <details class="Accordion">
        <summary class="Accordion-Title">アコーディオン1</summary>
        <p class="Accordion-Content"
          >アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。</p
        >
      </details>
    </div>

    <script>
      class Accordion {
        static #OPENED = "opened"
        static #OPENING = "opening"
        static #CLOSED = "closed"
        static #CLOSING = "closing"

        #details
        #summary
        #content
        #_status

        constructor(options) {
          if (!options.target) throw new Error("'target' must need Element.")

          this.#details = options.target
          this.#summary = this.#details.querySelector("summary")
          this.#content = this.#details.querySelector("summary + *")
          this.#status = Accordion.#CLOSED

          this.#details.style.transition = "height 300ms ease 0s"
          this.#details.style.overflow = "hidden"

          this.#summary.addEventListener("click", (event) =>
            this.#toggle(event)
          )

          this.#details.addEventListener("transitionend", () =>
            this.#onTransitionEnd()
          )
        }

        get #status() {
          return this.#_status
        }

        set #status(status) {
          this.#_status = status

          switch (status) {
            case Accordion.#OPENED:
              this.#details.setAttribute("open", "")
              this.#details.style.height = "auto"
              break

            case Accordion.#OPENING:
              this.#details.setAttribute("open", "")
              this.#details.style.height = this.#openingHeight
              break

            case Accordion.#CLOSED:
              this.#details.removeAttribute("open")
              this.#details.style.height = "auto"
              break

            case Accordion.#CLOSING:
              this.#details.setAttribute("open", "")
              this.#details.style.height = this.#closingHeight
              break
          }
        }
        get #openingHeight() {
          return (
            this.#summary.getBoundingClientRect().height +
            this.#content.getBoundingClientRect().height +
            this.#getVerticalBorderWidth() +
            "px"
          )
        }
        get #closingHeight() {
          return (
            this.#summary.getBoundingClientRect().height +
            this.#getVerticalBorderWidth() +
            "px"
          )
        }

        #getVerticalBorderWidth() {
          const computedStyle = getComputedStyle(this.#details)
          const borderTopWidth = parseInt(
            computedStyle.getPropertyValue("border-top-width")
          )
          const borderBottomWidth = parseInt(
            computedStyle.getPropertyValue("border-bottom-width")
          )
          return borderTopWidth + borderBottomWidth
        }

        #toggle(event) {
          event.preventDefault()

          switch (this.#status) {
            case Accordion.#OPENED:
              this.#details.style.height = this.#openingHeight
              setTimeout(() => {
                this.#status = Accordion.#CLOSING
              }, 10)
              break

            case Accordion.#OPENING:
              this.#status = Accordion.#CLOSING
              break

            case Accordion.#CLOSED:
              this.#details.style.height = this.#closingHeight
              setTimeout(() => {
                this.#status = Accordion.#OPENING
              }, 10)

              break

            case Accordion.#CLOSING:
              this.#status = Accordion.#OPENING
              break
          }
        }

        #onTransitionEnd() {
          if (this.#status === Accordion.#CLOSING) {
            this.#status = Accordion.#CLOSED
            return
          }

          if (this.#status === Accordion.#OPENING) {
            this.#status = Accordion.#OPENED
            return
          }
        }
      }

      document.querySelectorAll(".Accordion").forEach((element, index) => {
        new Accordion({
          target: element,
        })
      })
    </script>
  </body>
</html>
.Accordion {
  border: 1px solid #aaa;
  width: 100%;
}

.Accordion-Title {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px;
  background: #f7f7f7;
  cursor: pointer;
}

.Accordion-Title::-webkit-details-marker {
  display: none;
}

.Accordion-Content {
  padding: 1em;
}
/*! destyle.css v3.0.2 | MIT License | https://github.com/nicolas-cusan/destyle.css */

/* Reset box-model and set borders */
/* ============================================ */

*,
::before,
::after {
  box-sizing: border-box;
  border-style: solid;
  border-width: 0;
}

/* Document */
/* ============================================ */

/**
 * 1. Correct the line height in all browsers.
 * 2. Prevent adjustments of font size after orientation changes in iOS.
 * 3. Remove gray overlay on links for iOS.
 */

html {
  line-height: 1.15; /* 1 */
  -webkit-text-size-adjust: 100%; /* 2 */
  -webkit-tap-highlight-color: transparent; /* 3*/
}

/* Sections */
/* ============================================ */

/**
 * Remove the margin in all browsers.
 */

body {
  margin: 0;
}

/**
 * Render the `main` element consistently in IE.
 */

main {
  display: block;
}

/* Vertical rhythm */
/* ============================================ */

p,
table,
blockquote,
address,
pre,
iframe,
form,
figure,
dl {
  margin: 0;
}

/* Headings */
/* ============================================ */

h1,
h2,
h3,
h4,
h5,
h6 {
  font-size: inherit;
  font-weight: inherit;
  margin: 0;
}

/* Lists (enumeration) */
/* ============================================ */

ul,
ol {
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Lists (definition) */
/* ============================================ */

dt {
  font-weight: bold;
}

dd {
  margin-left: 0;
}

/* Grouping content */
/* ============================================ */

/**
 * 1. Add the correct box sizing in Firefox.
 * 2. Show the overflow in Edge and IE.
 */

hr {
  box-sizing: content-box; /* 1 */
  height: 0; /* 1 */
  overflow: visible; /* 2 */
  border-top-width: 1px;
  margin: 0;
  clear: both;
  color: inherit;
}

/**
 * 1. Correct the inheritance and scaling of font size in all browsers.
 * 2. Correct the odd `em` font sizing in all browsers.
 */

pre {
  font-family: monospace, monospace; /* 1 */
  font-size: inherit; /* 2 */
}

address {
  font-style: inherit;
}

/* Text-level semantics */
/* ============================================ */

/**
 * Remove the gray background on active links in IE 10.
 */

a {
  background-color: transparent;
  text-decoration: none;
  color: inherit;
}

/**
 * 1. Remove the bottom border in Chrome 57-
 * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
 */

abbr[title] {
  text-decoration: underline dotted; /* 2 */
}

/**
 * Add the correct font weight in Chrome, Edge, and Safari.
 */

b,
strong {
  font-weight: bolder;
}

/**
 * 1. Correct the inheritance and scaling of font size in all browsers.
 * 2. Correct the odd `em` font sizing in all browsers.
 */

code,
kbd,
samp {
  font-family: monospace, monospace; /* 1 */
  font-size: inherit; /* 2 */
}

/**
 * Add the correct font size in all browsers.
 */

small {
  font-size: 80%;
}

/**
 * Prevent `sub` and `sup` elements from affecting the line height in
 * all browsers.
 */

sub,
sup {
  font-size: 75%;
  line-height: 0;
  position: relative;
  vertical-align: baseline;
}

sub {
  bottom: -0.25em;
}

sup {
  top: -0.5em;
}

/* Replaced content */
/* ============================================ */

/**
 * Prevent vertical alignment issues.
 */

svg,
img,
embed,
object,
iframe {
  vertical-align: bottom;
}

/* Forms */
/* ============================================ */

/**
 * Reset form fields to make them styleable.
 * 1. Make form elements stylable across systems iOS especially.
 * 2. Inherit text-transform from parent.
 */

button,
input,
optgroup,
select,
textarea {
  -webkit-appearance: none; /* 1 */
  appearance: none;
  vertical-align: middle;
  color: inherit;
  font: inherit;
  background: transparent;
  padding: 0;
  margin: 0;
  border-radius: 0;
  text-align: inherit;
  text-transform: inherit; /* 2 */
}

/**
 * Reset radio and checkbox appearance to preserve their look in iOS.
 */

[type="checkbox"] {
  -webkit-appearance: checkbox;
  appearance: checkbox;
}

[type="radio"] {
  -webkit-appearance: radio;
  appearance: radio;
}

/**
 * Correct cursors for clickable elements.
 */

button,
[type="button"],
[type="reset"],
[type="submit"] {
  cursor: pointer;
}

button:disabled,
[type="button"]:disabled,
[type="reset"]:disabled,
[type="submit"]:disabled {
  cursor: default;
}

/**
 * Improve outlines for Firefox and unify style with input elements & buttons.
 */

:-moz-focusring {
  outline: auto;
}

select:disabled {
  opacity: inherit;
}

/**
 * Remove padding
 */

option {
  padding: 0;
}

/**
 * Reset to invisible
 */

fieldset {
  margin: 0;
  padding: 0;
  min-width: 0;
}

legend {
  padding: 0;
}

/**
 * Add the correct vertical alignment in Chrome, Firefox, and Opera.
 */

progress {
  vertical-align: baseline;
}

/**
 * Remove the default vertical scrollbar in IE 10+.
 */

textarea {
  overflow: auto;
}

/**
 * Correct the cursor style of increment and decrement buttons in Chrome.
 */

[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
  height: auto;
}

/**
 * 1. Correct the outline style in Safari.
 */

[type="search"] {
  outline-offset: -2px; /* 1 */
}

/**
 * Remove the inner padding in Chrome and Safari on macOS.
 */

[type="search"]::-webkit-search-decoration {
  -webkit-appearance: none;
}

/**
 * 1. Correct the inability to style clickable types in iOS and Safari.
 * 2. Fix font inheritance.
 */

::-webkit-file-upload-button {
  -webkit-appearance: button; /* 1 */
  font: inherit; /* 2 */
}

/**
 * Clickable labels
 */

label[for] {
  cursor: pointer;
}

/* Interactive */
/* ============================================ */

/*
 * Add the correct display in Edge, IE 10+, and Firefox.
 */

details {
  display: block;
}

/*
 * Add the correct display in all browsers.
 */

summary {
  display: list-item;
}

/*
 * Remove outline for editable content.
 */

[contenteditable]:focus {
  outline: auto;
}

/* Tables */
/* ============================================ */

/**
1. Correct table border color inheritance in all Chrome and Safari.
*/

table {
  border-color: inherit; /* 1 */
  border-collapse: collapse;
}

caption {
  text-align: left;
}

td,
th {
  vertical-align: top;
  padding: 0;
}

th {
  text-align: left;
  font-weight: bold;
}

より使いやすくする

初期化時にtransitionの設定もできるようにする

transitionのduration、timing-function、delayは初期化時に自由に決められたほうが使いやすそうですね。例えばこんな感じで使うイメージです。

document.querySelectorAll(".Accordion").forEach((element, index) => {
  new Accordion({
    target: element,
    duration: 500,
    timingFunction: "linear",
    delay: 50,
  })
})

実装としては単純に上記のような引数を受け入れプロパティに格納し、transitionの設定時にそのプロパティを呼び出します。

<script>
  class Accordion {
    static #OPENED = "opened"
    static #OPENING = "opening"
    static #CLOSED = "closed"
    static #CLOSING = "closing"

    #details
    #summary
    #content
    #duration = 300 //追加
    #timingFunction = "ease" //追加
    #delay = 0 //追加
    #_status

    constructor(options) {
      if (!options.target) throw new Error("'target' must need Element.")

      this.#details = options.target
      this.#summary = this.#details.querySelector("summary")
      this.#content = this.#details.querySelector("summary + *")
      this.#status = Accordion.#CLOSED

      //ここから
      this.#duration = options.duration ?? this.#duration
      this.#timingFunction = options.timingFunction ?? this.#timingFunction
      this.#delay = options.delay ?? this.#delay
      //ここまで追加

      this.#details.style.transition = `height ${this.#duration}ms ${
        this.#timingFunction
      } ${this.#delay}ms` //変更

      this.#details.style.overflow = "hidden"

      this.#summary.addEventListener("click", (event) =>
        this.#toggle(event)
      )

      this.#details.addEventListener("transitionend", () =>
        this.#onTransitionEnd()
      )
    }
    //省略
  }

  document.querySelectorAll(".Accordion").forEach((element, index) => {
    new Accordion({
      target: element,
      duration: 500,
      timingFunction: "linear",
      delay: 50,
    })
  })
</script>

プロパティに格納すべきかどうかは何とも言えませんが、ひとまずこの形で。

別途用意されるボタンなどのアニメーションを連動させる

アコーディオンメニューは多くの場合ボタンなどがあると思います。そのボタンの動きは完全に別で作っても良いのですが、できれば連動した方が良さそうです。実際に作ってみましょう。

追加するのは、

  • アクティブなCSSクラスの操作
  • transitionの各値をCSS変数に格納し、それをスタイルシートから利用するようにする

です。

まずは前者です。

<script>
  class Accordion {
    static #OPENED = "opened"
    static #OPENING = "opening"
    static #CLOSED = "closed"
    static #CLOSING = "closing"

    #details
    #summary
    #content
    #duration = 300
    #timingFunction = "ease"
    #delay = 0
    #activeClass = "AccordionIsOpen" //追加
    #_status

    constructor(options) {
      //省略
      this.#details.style.overflow = "hidden"
      this.#activeClass = options.activeClass ?? this.#activeClass //追加
    }

    
    set #status(status) {
      this.#_status = status

      switch (status) {
        case Accordion.#OPENED:
          this.#details.setAttribute("open", "")
          this.#details.classList.add(this.#activeClass) //追加
          this.#details.style.height = "auto"
          break

        case Accordion.#OPENING:
          this.#details.setAttribute("open", "")
          this.#details.classList.add(this.#activeClass) //追加
          this.#details.style.height = this.#openingHeight
          break

        case Accordion.#CLOSED:
          this.#details.removeAttribute("open")
          this.#details.classList.remove(this.#activeClass) //追加
          this.#details.style.height = "auto"
          break

        case Accordion.#CLOSING:
          this.#details.setAttribute("open", "")
          this.#details.classList.remove(this.#activeClass) //追加
          this.#details.style.height = this.#closingHeight
          break
      }
    }
    //省略
  }

  document.querySelectorAll(".Accordion").forEach((element, index) => {
    new Accordion({
      target: element,
      duration: 500,
      timingFunction: "linear",
      delay: 50,
    })
  })
</script>

#activeClassというプロパティを用意します。そして#statusにもこのactiveClassの操作を追加します。

次にCSS変数の設定です。

<script>
  class Accordion {
    static #OPENED = "opened"
    static #OPENING = "opening"
    static #CLOSED = "closed"
    static #CLOSING = "closing"

    #details
    #summary
    #content
    #duration = 300
    #durationName = "--accordion-duration" //追加
    #timingFunction = "ease"
    #timingFunctionName = "--accordion-timing-function" //追加
    #delay = 0
    #delayName = "--accordion-delay" //追加
    #activeClass = "AccordionIsOpen"
    #_status

    constructor(options) {
      if (!options.target) throw new Error("'target' must need Element.")

      this.#details = options.target
      this.#summary = this.#details.querySelector("summary")
      this.#content = this.#details.querySelector("summary + *")
      this.#status = Accordion.#OPENED

      this.#duration = options.duration ?? this.#duration
      //ここから
      this.#durationName = options.durationName ?? "--accordion-duration"
      this.#details.style.setProperty(this.#durationName, this.#duration + "ms")
      //ここまで追加

      this.#timingFunction = options.timingFunction ?? this.#timingFunction
      //ここから
      this.#timingFunctionName =
        options.timingFunctionName ?? "--accordion-timing-function"
      this.#details.style.setProperty(
        this.#timingFunctionName,
        this.#timingFunction
      )
      //ここまで追加

      this.#delay = options.delay ?? this.#delay
      //ここから
      this.#delayName = options.delayName ?? "--accordion-delay"
      this.#details.style.setProperty(this.#delayName, this.#delay)
      //ここまで追加

      //省略
    }
  }

  document.querySelectorAll(".Accordion").forEach((element, index) => {
    new Accordion({
      target: element,
      duration: 500,
      timingFunction: "linear",
      delay: 50,
    })
  })
</script>

これもそれぞれのCSS変数名をプロパティに格納し、detailsのCSS変数として定義します(style.setProperty())。

こうすることで、例えばボタンがある場合のtransitionの値をアコーディオンメニューのものと一致させることができます。

<details class="Accordion">
  <summary class="Accordion-Title">
    <span class="Accordion-Title-Text">アコーディオン2</span>
    <img src="./images/arrow.svg" alt="" class="Accordion-Title-Icon" />
  </summary>
  <p class="Accordion-Content"
    >アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。アコーディオンのコンテンツです。</p
  >
</details>
.Accordion-Title-Icon {
  width: 20px;
  transition-property: transform;
  transition-duration: var(--accordion-duration);
  transition-timing-function: var(--accordion-timing-function);
  transition-delay: var(--accordion-delay);
}

.AccordionIsOpen .Accordion-Title-Icon {
  transform: rotateZ(-180deg);
}

もちろんそれぞれのCSS変数の名前やアクティブなクラスの名前も初期化時に自由に指定できます。

document.querySelectorAll(".Accordion").forEach((element, index) => {
  new Accordion({
    target: element,
    durationName: "--test-duration",
    timingFunctionName: "--test-timing-function",
    delayName: "--test-delay",
  })
})

初期で開いているか閉じているかを選べるようにする

これは比較的簡単で、初期化時に指定があればOPENED、なければCLOSEDにします。

<script>
  class Accordion {
    //省略

    constructor(options) {
      if (!options.target) throw new Error("'target' must need Element.")

      this.#details = options.target
      this.#summary = this.#details.querySelector("summary")
      this.#content = this.#details.querySelector("summary + *")
      //変更
      this.#status = options.isOpenOnDefault
        ? Accordion.#OPENED
        : Accordion.#CLOSED

      //省略
    }
    //省略
  }

  document.querySelectorAll(".Accordion").forEach((element, index) => {
    new Accordion({
      target: element,
      isOpenOnDefault: index === 0,
    })
  })
</script>

isOpenOnDefaultのtrue/falseを判定します。複数のうち全部を最初から開きたいことは少ないと思うので、上記のように指定すれば任意の要素だけ指定できます。

まとめ

というわけでアコーディオンメニューについてでした。長い上に結構複雑なので読むのが大変かもしれませんが、ぜひ参考にしてみてください。

理想としては今回のコードをCDNで配布できればと思っていますが、単純にそういうことをやったことがないのもあって一旦やめています。

もし記事に間違いや改善点があればぜひコメントください〜。

コメント

  1. May より:

    detailsタグ使う方法はこちらの記事で紹介されている方がしっくりきますね
    こっちの方がJSがきれいですし、サイト検索の開閉にも対応していてユーザビリティも高そうです

    https://www.tak-dcxi.com/article/accordion-slide-animation-can-be-implemented-in-two-line-of-css

    • 小林 秀樹 小林 秀樹 より:

      あ、display: grid;を使った方法ですね。存在は知っていたのですが記事として整理できていなかったので自分もまとめておこうと思います。
      情報ありがとうございました!

タイトルとURLをコピーしました