5
5

StimulusとTailwind CSSでa11yを考慮したキーボード・インタラクション全部入りアコーディオンを実装する

Last updated at Posted at 2024-09-03

StimulusとTailwind CSSを用いて、アクセシビリティを考慮しつつ、キーボード・インタラクションを全て含んだアコーディオンを実装してみたいと思います。

アコーディオンとは

アコーディオンは、インタラクティブな見出しが垂直に積み重ねられたものです。それぞれの見出しには、セクションの内容を代表するタイトル、抜粋、サムネイルが含まれています。 見出しは、ユーザが関連する内容のセクションを表示したり隠したりできるように、コントロールとして機能します。 アコーディオンは、一般的に、複数のコンテンツのセクションをシングル・ページに表示するとき、スクロールの必要性を軽減するために使用されます。

3.1 アコーディオン(表示/非表示機能を伴ったセクション) | WAI-ARIA オーサリング・プラクティス 1.1

テキストの改行が多いスマホサイトなどでよく見かけるイメージです。

キーボード・インタラクション全部入りアコーディオン

アクセシビリティを考慮したアコーディオンの実装方法についてはいくつか記事があるのですが、オプションになっているキーボード・インタラクション(下向き矢印キー、上向き矢印キー、ホームキー、エンドキー)も実装している記事は見当たらなかったので、今回これらも全部入れて実装してみました。

See the Pen QIITA_STIMULUS_ACCESIBLE_ACCORDION_1 by Yoruaki (@yoruaki) on CodePen.

HTMLを書いてみる

アコーディオン
<div
    class="m-4 w-40 border border-black rounded-md overflow-hidden leading-relaxed"
>
   <section>
        <h3>
            <button
                type="button"
                id="vegetable"
                aria-controls="panel-vegetable"
                aria-expanded="false"
                class="flex w-full items-center px-2 py-1 font-bold hover:bg-slate-100 focus:-outline-offset-4"
            >
                <img
                    src="https://yoruaki.com/assets/qiita/VwJBvye/triangle.svg"
                    width="16"
                    height="16"
                    alt=""
                    class="transition-all"
                >やさい
            </button>
        </h3>
        <div
            id="panel-vegetable"
            role="region"
            aria-labelledby="vegetable"
            inert
            class="grid grid-rows-[repeat(1,_minmax(0,_0fr))] pl-6 overflow-hidden transition-all"
        >
            <ul class="pb-3">
                <li>
                    <a
                        href="https://example.com/potato"
                        target="_blank"
                        class="text-blue-700 underline hover:text-red-600"
                    >じゃがいも</a>
                </li>
                <li>
                    <a
                        href="https://example.com/carrot"
                        target="_blank"
                        class="text-blue-700 underline hover:text-red-600"
                    >にんじん</a>
                </li>
                <li>
                    <a
                        href="https://example.com/onion"
                        target="_blank"
                        class="text-blue-700 underline hover:text-red-600"
                    >たまねぎ</a>
                </li>
            </ul>
        </div>
    </section>
    <section>...</section>
    <section>...</section>
</div>

要素の説明

要素 属性 説明
<div> アコーディオン全体のラッパー。
class="..." Tailwind CSSでスタイル調整。
<section> アコーディオン・ヘッダー、アコーディオン・パネルを含んだセクション。
<h3> アコーディオン・ヘッダー
今回は見出し要素を使っているが、role="heading"を持った要素なら何でもOK。
aria-levelは問わない(<h1-h6>どれでもOK)。
<button> セクションの内容を表示したり隠したりするコントローラー。
また、heading要素の中はこのbutton要素のみでなければならない。
type="button" ボタン。
id="vegetable" アコーディオン・パネルからのaria-labelledbyの指定先用のID。
aria-controls="panel-vegetable" このボタンがどのアコーディオン・パネルを制御しているのか。
今回は#panel-vegetable
aria-expanded="false" このボタンの制御先が展開されているか(true)、折りたたまれているか(false)。
今回はデフォルトは全て閉じている状態のため、false
class="..." Tailwind CSSでスタイル調整。
<img> class="transition-all" 装飾用の画像。
アコーディオン・パネル非表示時は右向き三角、展開時は下向き三角にアニメーションさせる。
それ以外はいつもの属性を付与。
<div> アコーディオン・パネル
id="panel-vegetable" アコーディオン・ヘッダーからの制御(aria-controls)のための指定用ID。
role="region" 重要なコンテンツであると伝えたいが、他に適切なランドマークロールがない場合に使用する(アコーディオンを意味するロールは今のところ無い)。
これは、他のランドマークロールのどれも適切でないときでも、汎用のランドマークを提供することで、人々が容易にナビゲートできるようにするために使用されます。

ARIA: region ロール - アクセシビリティ | MDN
aria-labelledby="vegetable" この要素(リージョン)のラベル付け。
今回だと#vegetable。つまり、「やさい」に関するものであると示している。
inert パネルはデフォルトでは閉じている状態なので、この属性を付与することでフォーカス関連のイベントや支援技術からのイベント、ユーザーから送られる入力イベントもブラウザが無視するようになる。
aria-hidden="true"display:none;でも同じことができるが、今回はアニメーションもさせたかったのでこちらを選択。
class="..." Tailwind CSSでスタイル調整。
<ul>
<li>
<a>
フォーカスできれば何でもよかったので、リンクをリストで並べてスタイル調整しただけのもの。
今回は動作確認のためにフォーカス可能な要素にしたが、フォーカスできない要素でもOK。

Accordion Controller

まずはアコーディオンにStimulusを効かせるため、全体のラッパーにdata-controller属性を付与します。

Controllers
<div
    data-controller="accordion"
    class="m-4 w-40 border border-black rounded-md overflow-hidden leading-relaxed"
>
    <section>...</section>
    <section>...</section>
    <section>...</section>
</div>

CSS Classes

次に、アコーディオン・ヘッダーとアコーディオン・パネルが開いているときのclassを指定します。

アコーディオン・ヘッダーでは、右向きの三角(▶)を下向き(▼)にしたいです。
なのでtransform: rotate(90deg);が指定されているrotate-90をセットします。

アコーディオン・パネルでは、高さ0の閉じているパネルを、開いている状態(コンテンツの高さ)にしたいです。
通常であればheight: 0;height: auto;で問題ないのですが、残念ながらこれだとtransitionプロパティでアニメーションしてくれません。

なので今回は別のアプローチで、grid-template-rows: repeat(1, minmax(0, 1fr));になるgrid-rows-1セットします。

grid-template-rows: repeat(1, minmax(0, 0fr));grid-template-rows: repeat(1, minmax(0, 1fr));に変化することで、意図したアニメーションになってくれます。

CSS Classes
<div
    data-controller="accordion"
    data-accordion-open-header-class="rotate-90"
    data-accordion-open-panel-class="grid-rows-1"
    class="m-4 w-40 border border-black rounded-md overflow-hidden leading-relaxed"
>
    <section>...</section>
    <section>...</section>
    <section>...</section>
</div>

このテクニックは、コリスの以下の記事を参考にしています。

Targets

次に取得したい要素にdata-accordion-target属性を付与します。

今回はアコーディオン・ヘッダー(button要素)とアコーディオン・パネル(div要素)を取得します。

Targets (data-accordion-target)
<div
    data-controller="accordion"
    data-accordion-open-header-class="rotate-90"
    data-accordion-open-panel-class="grid-rows-1"
    class="m-4 w-40 border border-black rounded-md overflow-hidden leading-relaxed"
>
    <section>
        <h3>
            <button
                type="button"
                id="vegetable"
                aria-controls="panel-vegetable"
                aria-expanded="false"
                data-accordion-target="header"
                class="flex w-full items-center px-2 py-1 font-bold hover:bg-slate-100 focus:-outline-offset-4"
            >...</button>
        </h3>
        <div
            id="panel-vegetable"
            role="region"
            aria-labelledby="vegetable"
            inert
            data-accordion-target="panel"
            class="grid grid-rows-[repeat(1,_minmax(0,_0fr))] pl-6 overflow-hidden transition-all"
        >
           ...
        </div>
    </section>
    <section>...</section>
    <section>...</section>
</div>

基本機能

アコーディオン・ヘッダーをクリックしたらアコーディオン・パネルが開く、もう一度アコーディオン・ヘッダーをクリックしたらアコーディオン・パネルが閉じる。

これの繰り返しです。

Actions (Click Event)

アコーディオン・ヘッダーにdata-action属性を付与します。

Actions (Click Event)
<button
    type="button"
    id="vegetable"
    aria-controls="panel-vegetable"
    aria-expanded="false"
    data-accordion-target="header"
    data-action="click->accordion#togglePanel"
    class="flex w-full items-center px-2 py-1 font-bold hover:bg-slate-100 focus:-outline-offset-4"
>
    ...
</button>

Stimulusでは、アコーディオン・ヘッダー(button要素)のaria-expanded属性のtrue/falseをトグルさせ、子要素のimg要素ではopenHeaderClassをトグルさせます。

次にアコーディオン・ヘッダーに紐づくアコーディオン・パネルでopenPanelClassinert属性をトグルさせます。

Stimulus
app.register('accordion', class extends Controller {
    static targets = ['header', 'panel'];
    static classes = ['openHeader', 'openPanel'];

    togglePanel(evt) {
        const btn = evt.currentTarget;
        const img = btn.querySelector('img');
        const panelId = btn.getAttribute('aria-controls');
        const panel = this.panelTargets.find(p => p.id === panelId);

        btn.ariaExpanded = (btn.ariaExpanded === 'true') ? 'false' : 'true';

        if (img) {
            img.classList.toggle(this.openHeaderClass);
        }

        if (panel) {
            panel.classList.toggle(this.openPanelClass);
            this.toggleInertAttribute(panel);
        }
    }

    toggleInertAttribute(panel) {
        if (panel.hasAttribute('inert')) {
            panel.removeAttribute('inert');
        } else {
            panel.setAttribute('inert', '');
        }
    }
});

以上で、基本的なアコーディオンに関しては一旦完成です。

アコーディオンのキーボード・インタラクション

やっと今回のメインですが、まずはアコーディオンにどのようなキーボード・インタラクションがあるのか確認してみます。

キーボード操作 説明
Enter or Space フォーカスが折りたたまれたパネルのヘッダーにあるとき、対応するパネルを開く。もし仕様により一つのパネルだけ開くことを許していて、他のパネルが開かれるた場合、そのパネルを閉じる。

フォーカスが開いたパネルのヘッダーにあるとき、もし仕様が閉じることをサポートしていた場合、そのパネルを閉じる。

いくつかの仕様では、常に一つのパネルが開かれていることを要求し、一つのパネルだけが開くことを許可し、それらは閉じる機能をサポートしない。
Tab フォーカスを次のフォーカス可能な要素に移動する。アコーディオンの全てのフォーカス可能な要素は、ページのタブ・シーケンスの中に含まれる。
Shift + Tab Tabの逆。

まずはここまでが基本的なアコーディオンのキーボード・インタラクションです。

Enter or Spaceキーの説明についてですが、アコーディオンが開くパネルは常に1つで、他のパネルを開くと、すでに開いていたパネルは閉じる。また、それらは閉じる機能を持っておらず、全て閉じているという状態がない(常に1つのパネルが開かれている)アコーディオンというものです。

昔は見た記憶があるのですが、今ではそれぞれに開閉機能があり、全部開くことも全部閉じることも可能なアコーディオンが主流だと思います。公式のサンプルもそうなってますしね。

また、これらのキーボード・インタラクションはbutton要素を使用することで全てクリアすることができます。なるべくbutton要素を使いましょう。

オプション(任意)扱いのキーボード・インタラクション

キーボード操作 説明
Down Arrow アコーディオン・ヘッダーの上にフォーカスがある場合、フォーカスは次のヘッダーに移動する。
フォーカスが最後のアコーディオン・ヘッダーにある場合、最初のアコーディオン・ヘッダーに移動する。
Up Arrow Down Arrowの逆。
Home アコーディオン・ヘッダーの上にフォーカスがある場合、フォーカスを最初のアコーディオン・ヘッダーに移動する。
End アコーディオン・ヘッダーの上にフォーカスがある場合、フォーカスを最後のアコーディオン・ヘッダーに移動する。

アコーディオン・ヘッダーのフォーカスに関する挙動が主ですね。

Actions (Keyboard Event)

アコーディオン・ヘッダーにdata-action属性を付与します。

Actions (Keyboard Event)
<button
    type="button"
    id="vegetable"
    aria-controls="panel-vegetable"
    aria-expanded="false"
    data-accordion-target="header"
    data-action="
        click->accordion#togglePanel
        keydown.down->accordion#moveFocusNextHeader
        keydown.up->accordion#moveFocusPrevHeader
        keydown.home->accordion#moveFocusHomeHeader
        keydown.end->accordion#moveFocusEndHeader
    "
    class="flex w-full items-center px-2 py-1 font-bold hover:bg-slate-100 focus:-outline-offset-4"
>
    ...
</button>

属性値を見ただけで何をしたいのかが直感的に分かりやすくて良いですね!

基本的にアコーディオン・パネルをdata-accordion-targetで取得して、それらは配列で取れるので、イベントが発生した要素のインデックス番号を取得して、次に移動するか前に移動するか、最初に移動するか最後に移動するか、を制御するだけです。

Stimulus
app.register('accordion', class extends Controller {
    static targets = ['header', 'panel'];

    moveFocusNextHeader(evt) {
        this.moveFocus(evt, 1);
    }

    moveFocusPrevHeader(evt) {
        this.moveFocus(evt, -1);
    }

    moveFocusHomeHeader(evt) {
        evt.preventDefault();
        const headers = this.headerTargets;
        headers[0].focus();
    }

    moveFocusEndHeader(evt) {
        evt.preventDefault();
        const headers = this.headerTargets;
        headers[headers.length - 1].focus();
    }

    moveFocus(evt, direction) {
        evt.preventDefault();
        const headers = this.headerTargets;
        const length = headers.length;
        const index = headers.indexOf(evt.currentTarget);
        let newIndex = (index + direction + length) % length;

        headers[newIndex].focus();
    }
});

以上でキーボード・インタラクション全部入りアコーディオンの実装は完了です。

See the Pen QIITA_STIMULUS_ACCESIBLE_ACCORDION_1 by Yoruaki (@yoruaki) on CodePen.

なかなか使いやすいと思うんですよね。

記事を書いてる途中で気づいたこと

今回APGのAccordion Patternに沿って実装しつつ記事を書いていたため、最初は気づかなかったのですが、今だったらdetails要素とsummary要素使うよなぁ、と思いましたが後の祭り😇

ただ、details要素を使用するとブラウザがデフォルトの開閉操作を処理するため、この標準動作がCSSのトランジションを阻害する可能性はあります。試しに作り直してみましたが、パネルのアニメーション部分が上手くいきませんでした🫠

details要素とsummary要素で書き直したアコーディオン

See the Pen QIITA_STIMULUS_ACCESIBLE_ACCORDION_2 by Yoruaki (@yoruaki) on CodePen.

最後に

StimulusとTailwind CSSを用いて、アクセシビリティを考慮しつつ、キーボード・インタラクションを全て含んだアコーディオンを実装してみました。

基本動作部分については、button要素やdetails要素、summary要素を用いれば比較的簡単に実装できるかと思います。

また、オプションのキーボード・インタラクションについても、ヘッダーとパネルがたくさんあるようなアコーディオンだと、ヘッダー間を移動したいという要望もあるのではないでしょうか?

特に既存の操作を邪魔するわけでもないので、もし可能ならばこの機能が実装してあると、いろいろな環境の人に喜ばれるんじゃないかなと思った次第です。

5
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5