📌

アクセシブルなアコーディオンの実装について考える

2022/07/15に公開

Q&Aなどでよく見かけるアコーディオンUIですが、アクセシビリティを考慮すると案外実装がややこしかったりします。
ARIA Authoring Practices Guide (APG)を参考に、アコーディオンの実装について考えてみようというのが本記事の趣旨です。

ARIA APGのアコーディオンに関するページは以下になります。
https://www.w3.org/WAI/ARIA/apg/patterns/accordion/

そもそもアコーディオンとは

そもそも本記事で扱うアコーディオンとはどのようなUIなのでしょうか。
About This Patternの項目を読んでみましょう。

  • 垂直方向に積み重ねられたインタラクディブな見出しのセットである。
  • 見出しであるヘッダーとコンテンツであるパネルからなる。
  • ヘッダーはコンテンツのセクションを表すタイトル(やコンテンツのスニペットやサムネイル)を含んでいる。
  • ヘッダーはパネル開閉のコントロールとしても機能する。
  • 1つのページに複数のセクションを表示する時、スクロール量を減らすために使用されることが多い。

ほかにもポイントはあると思いますが、ざっくりまとめると上記のような感じだと思います。

実装

アコーディオンがどのようなUIなのか、ざっくりわかったところで実装に移ります。

ベースコーディング

とりあえずなにも気にせず実装してみます。

ヘッダーをクリックしたらパネルが開閉するだけの、非常にシンプルな実装ではありますが、最低限の機能はあるかと思います。
この実装をベースとして話を進めていきます。

html
<p class="accordion-header">
  <!-- data-panelで対応するpanelのidを取得 -->
  <!-- .__openでアイコンの向き変更 -->
  <span class="accordion-trigger" data-panel="accordion-panel-1">
    アコーディオンの見出し01<span class="accordion-icon"></span>
  </span>
</p>
<!-- .__closeでパネルの開閉 -->
<div id="accordion-panel-1" class="accordion-panel __close">
  <p class="accordion-panel__text">
    アコーディオンの内容。アコーディオンの内容。アコーディオンの内容。
  </p>
</div>
css
/* .__openでアイコンの向きを変える */
.accordion-trigger.__open .accordion-icon {
  transform: rotate(-45deg);
}
/* .__closeでパネルを開閉する */
.accordion-panel.__close {
  display: none;
}
javascript
triggers.forEach((trigger) => {
  // 対応するパネルを取得
  const dataPanel = trigger.dataset.panel;
  const panel = document.getElementById(dataPanel);

  trigger.addEventListener("click", (e) => {
    // 開閉状態を取得
    const target = e.currentTarget;
    const isOpen = trigger.classList.contains("__open");

    if (isOpen) {
      // パネルを閉じる
      target.classList.remove("__open");
      panel.classList.add("__close");
    } else {
      // パネルを開く
      target.classList.add("__open");
      panel.classList.remove("__close");
    }
  });
});

HTMLタグの修正

さて、ここからアクセシブルなアコーディオンの実装を考えていくわけですが、最初はマークアップの構造や適切なタグの選択から考えていきましょう。
ARIA APGにはキーボード操作やaria属性についての説明も載っていますが、適切なマークアップをすることで不要な実装を防ぐことができるので、まずはそこから始めます。

WAI-ARIA Roles, States, and Propertiesには以下のような記述があります。

  • The title of each accordion header is contained in an element with role button.
  • Each accordion header button is wrapped in an element with role heading that has a value set for aria-level that is appropriate for the information architecture of the page.
    • If the native host language has an element with an implicit heading and aria-level, such as an HTML heading tag, a native host language element may be used.
    • The button element is the only element inside the heading element. That is, if there are other visually persistent elements, they are not included inside the heading element.

上記を踏まえ、本記事では以下のようにマークアップしたいと思います。

  • アコーディオンのタイトルをbuttonに含める。
  • buttonhタグでラップする。

以下が主な修正コードになります。

html
- <p class="accordion-header">
-   <span class="accordion-trigger" data-panel="accordion-panel-1">
-     アコーディオンの見出し01<span class="accordion-icon"></span>
-   </span>
- </p>
+ <h2 class="accordion-header">
+   <button class="accordion-trigger" data-panel="accordion-panel-1">
+     アコーディオンの見出し01<span class="accordion-icon"></span>
+   </button>
+ </h2>
  <div id="accordion-panel-1" class="accordion-panel __close">
    <p class="accordion-panel__text">
      アコーディオンの内容。アコーディオンの内容。アコーディオンの内容。
    </p>
  </div>

全体のコードはこちら。

キーボード操作

次にキーボード操作を考えます。
Keyboard Interactionを読んでみましょう。

  • Enter or Space:
    • When focus is on the accordion header for a collapsed panel, expands the associated panel. If the implementation allows only one panel to be expanded, and if another panel is expanded, collapses that panel.
    • When focus is on the accordion header for an expanded panel, collapses the panel if the implementation supports collapsing. Some implementations require one panel to be expanded at all times and allow only one panel to be expanded; so, they do not support a collapse function.
  • Tab: Moves focus to the next focusable element; all focusable elements in the accordion are included in the page Tab sequence.
  • Shift + Tab: Moves focus to the previous focusable element; all focusable elements in the accordion are included in the page Tab sequence.

ARIA APGではオプショナルな項目(矢印キー関連)についての説明もありますが、今回は実装しません。
またアコーディオンの挙動として、展開されるパネルが常に1つのみのパターンと、複数のパネルを同時に展開できるパターンがありますが、本記事では後者を想定しています(ベースの実装もそのような形になっています)。

したがって上記を踏まえると、キーボード操作は以下のようになります。

  • ヘッダーにフォーカスがある状態でEnterSpaceを押下した時、パネルが閉じていたら開く。パネルが開いていたら閉じる。
  • Tabを押下した時、フォーカスが次のフォーカス可能な要素に移る。
  • Shift + Tabを押下した時、フォーカスが前のフォーカス可能な要素に移る。

実のところ、上記の挙動はHTMLタグの修正で、すでに対応済みとなります。
適切なマークアップをすることで不要な実装を防げることが実感できましたね。

WAI-ARIAの実装

最後にWAI-ARIAの実装について考えてみます。
WAI-ARIA Roles, States, and Propertiesには以下のような記述もあります。

  • If the accordion panel associated with an accordion header is visible, the header button element has aria-expanded set to true. If the panel is not visible, aria-expanded is set to false.
  • The accordion header button element has aria-controls set to the ID of the element containing the accordion panel content

上記を参考に以下のように実装します。

  • ヘッダーのbuttonaria-expanded をセットする。関連するパネルが開いている時はtrue、閉じている時はfalseをセットする。
  • ヘッダーのbuttonに関連するパネルのid属性を指定した aria-controls をつける。

本記事では触れませんが、場合によってはパネルにregion roleを付与することも可能です(もしくはsectionタグを使う)。その場合、aria-labelledbyでパネルの開閉を制御するbuttonを参照してください。詳しくはARIA APGをご参照ください。

以下が主な修正コードになります。

html
  <h2 class="accordion-header">
    <button
      class="accordion-trigger"
-     data-panel="accordion-panel-1"
+     aria-controls="accordion-panel-1"
+     aria-expanded="false"
    >
      アコーディオンの見出し01<span class="accordion-icon"></span>
    </button>
  </h2>
  <div id="accordion-panel-1" class="accordion-panel __close">
    <p class="accordion-panel__text">
      アコーディオンの内容。アコーディオンの内容。アコーディオンの内容。
    </p>
  </div>
css
- .accordion-trigger.__open .accordion-icon {
+ .accordion-trigger[aria-expanded="true"] .accordion-icon {
    transform: rotate(-45deg);
  }
javascript
  triggers.forEach((trigger) => {
-   const dataPanel = trigger.dataset.panel;
-   const panel = document.getElementById(dataPanel);
+   const controls = trigger.getAttribute("aria-controls");
+   const panel = document.getElementById(controls);

    trigger.addEventListener("click", (e) => {
      const target = e.currentTarget;
-     const isOpen = target.getAttribute("aria-expanded") === "true";
+     const isOpen = trigger.classList.contains("__open");

      if (isOpen) {
-       target.classList.remove("__open");
+       target.setAttribute("aria-expanded", "false");
        panel.classList.add("__close");
      } else {
-       target.classList.add("__open");
+       target.setAttribute("aria-expanded", "true");
        panel.classList.remove("__close");
      }
    });
  });

全体のコードはこちら。

お疲れ様でした!以上で実装完了となります。

おまけ details/summaryの話

ARIA APGのアコーディオンの項目に記載はありませんが、details / summaryを使用すると、ここまで考慮してきた諸々をあまり意識することなく 似たUI(完全に代替されるものではない)を実装できます。

一方で、このUIをアコーディオンと呼ぶのかは色々議論があるようで、以下の記事はとても参考になりました。

https://adrianroselli.com/2019/04/details-summary-are-not-insert-control-here.html
https://daverupert.com/2019/12/why-details-is-not-an-accordion/

現状、summaryの中にhタグを入れ子にするとhタグの見出しロールが打ち消されうる、という事実は知っておいて損はないと思います。
実際、私の使用しているMacのVoiceOverで確認したところ、「見出しレベル〇〇」という読み上げはされませんでした。

上記の理由から ARIA APG におけるUIとしては、アコーディオンというよりもDisclosure(ディスクロージャー)に近いんじゃないかという意見もあるようですね。

おまけその2 hidden="until-found"の話

hidden="until-found"について最近知ったので追記しておきます。

上記では触れていませんでしたが、details/summaryを使用した際のメリットの1つとして、ページ内検索で単語がヒットした際にアコーディオンが開く、というものがあります。

私が確認する限りARIA APGのアコーディオンの項目には、ページ内検索についての記述はありませんでした。Accordion Exampleを確認しても、アコーディオンが折りたたまれた状態のページ内検索では隠されている単語を発見することはありません。

hidden="until-found"を使うことで、アコーディオンが折りたたまれた状態でも隠された単語を見つけ、かつアコーディオンを開くことができます。

https://caniuse.com/?search=until-found

とりあえず実装してみましょう。

ブラウザで「検索するとヒットします。」の文字をページ内検索してみてください。アコーディオンが折りたたまれた状態でも単語がヒットし、かつアコーディオンが開かれることを確認できると思います。

コードを少し見てみます。

まずHTMLのパネルの部分にhidden="until-found"を付与します。

html
  <div
    id="accordion-panel-1"
-   class="accordion-panel __close"
+   class="accordion-panel"
+   hidden="until-found"
  >
    <p class="accordion-panel__text">
      アコーディオンの内容。アコーディオンの内容。アコーディオンの内容。
    </p>
  </div>

hidden="until-found"が付与された要素はcontent-visibility:hiddenのスタイルが適用されます。display:noneで要素を消してしまうと、検索してもヒットしなくなってしまうので、こちらの記述を修正します。

css
- .accordion-panel.__close {
-  display: none;
- }

hidden="until-found"が付与されている要素ではbeforematchイベントが使用できます。詳細を知りたい方はMDNを参照してください。ページ内検索でhidden="until-found"内の隠された要素を発見する時に、beforematchイベントを受け取ります。

下記ではbeforematchでイベントを受け取った時にhidden属性を除くということをしています。また整合性を保つためにaria属性の処理を忘れないようにしましょう。

js
 triggers.forEach((trigger) => {
   const controls = trigger.getAttribute("aria-controls");
   const panel = document.getElementById(controls);

   trigger.addEventListener("click", (e) => {
     const target = e.currentTarget;
     const isOpen = target.getAttribute("aria-expanded") === "true";

     if (isOpen) {
       // アコーディオンを閉じる
       target.setAttribute("aria-expanded", "false");
-      panel.classList.add("__close");
+      panel.setAttribute("hidden", "until-found");
     } else {
       // アコーディオンを閉じる
       target.setAttribute("aria-expanded", "true");
-      panel.classList.remove("__close");
+      panel.removeAttribute("hidden");
     }
   });

+  // hidden="until-found"で検索がヒットした時
+  panel.addEventListener("beforematch", (e) => {
+    const target = e.currentTarget;
+
+    // アコーディオンを開く
+    target.removeAttribute("hidden");
+
+   // トリガーの処理
+    const id = target.id;
+    const trigger = document.querySelector(`button[aria-controls="${id}"]`);
+    trigger.setAttribute("aria-expanded", "true");
+  });
 });

現時点ではサポートされているブラウザが少ないので、実践投入できるかといわれると微妙なところはありますが、将来的にはとても楽しみな技術だと思います。

おわりに

アクセシブルなアコーディオンの実装について考えるという内容でした。
一見単純なUIに見えても、実際に実装しようとするとややこしかったりするので、しっかり調べることが大切だなと思いました。
また本記事では一部実装を見送った(オプショナルなキーボード操作など)箇所もあるので、興味がある方は、ぜひARIA APGを一読してみてください。

参考

https://www.w3.org/TR/wai-aria-1.2/
https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
https://developer.chrome.com/articles/hidden-until-found/
https://adrianroselli.com/2020/05/disclosure-widgets.html
https://accessible-usable.net/2020/06/entry_200613.html
https://adrianroselli.com/2019/04/details-summary-are-not-insert-control-here.html
https://mui.com/material-ui/react-accordion/
https://getbootstrap.jp/docs/5.0/components/accordion/

Discussion