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

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

## アコーディオンとは

> アコーディオンは、インタラクティブな見出しが垂直に積み重ねられたものです。それぞれの見出しには、セクションの内容を代表するタイトル、抜粋、サムネイルが含まれています。 見出しは、ユーザが関連する内容のセクションを表示したり隠したりできるように、コントロールとして機能します。 アコーディオンは、一般的に、複数のコンテンツのセクションをシングル・ページに表示するとき、スクロールの必要性を軽減するために使用されます。
>
> [3.1 アコーディオン(表示/非表示機能を伴ったセクション) | WAI-ARIA オーサリング・プラクティス 1.1](https://waic.jp/translations/2019/NOTE-wai-aria-practices-1.1-20190207/#accordion)</cite></div>

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

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

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

<p class="codepen" data-height="500" data-default-tab="result" data-slug-hash="VwJBvye" data-pen-title="QIITA_STIMULUS_ACCESIBLE_ACCORDION_1" data-user="yoruaki" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/yoruaki/pen/VwJBvye">
  QIITA_STIMULUS_ACCESIBLE_ACCORDION_1</a> by Yoruaki (<a href="https://codepen.io/yoruaki">@yoruaki</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

## HTMLを書いてみる

```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>`||**アコーディオン・ヘッダー**。<br>今回は見出し要素を使っているが、`role="heading"`を持った要素なら何でもOK。<br>`aria-level`は問わない（`<h1-h6>`どれでもOK）。|
|`<button>`||セクションの内容を表示したり隠したりするコントローラー。<br>また、`heading`要素の中はこの`button`要素のみでなければならない。|
||`type="button"`|ボタン。|
||`id="vegetable"`|アコーディオン・パネルからの`aria-labelledby`の指定先用のID。|
||`aria-controls="panel-vegetable"`|このボタンがどのアコーディオン・パネルを制御しているのか。<br>今回は`#panel-vegetable`|
||`aria-expanded="false"`|このボタンの制御先が展開されているか（`true`）、折りたたまれているか（`false`）。<br>今回はデフォルトは全て閉じている状態のため、`false`。|
||`class="..."`|Tailwind CSSでスタイル調整。|
|`<img>`|`class="transition-all"`|装飾用の画像。<br>アコーディオン・パネル非表示時は右向き三角、展開時は下向き三角にアニメーションさせる。<br>それ以外はいつもの属性を付与。|
|`<div>`||**アコーディオン・パネル**。|
||`id="panel-vegetable"`|アコーディオン・ヘッダーからの制御（`aria-controls`）のための指定用ID。|
||`role="region"`|重要なコンテンツであると伝えたいが、他に適切なランドマークロールがない場合に使用する（アコーディオンを意味するロールは今のところ無い）。<div><blockquote cite="https://developer.mozilla.org/ja/docs/Web/Accessibility/ARIA/Roles/region_role">これは、他のランドマークロールのどれも適切でないときでも、汎用のランドマークを提供することで、人々が容易にナビゲートできるようにするために使用されます。<br><br><a href="https://developer.mozilla.org/ja/docs/Web/Accessibility/ARIA/Roles/region_role">ARIA: region ロール - アクセシビリティ \| MDN</a></blockquote>|
||`aria-labelledby="vegetable"`|この要素（リージョン）のラベル付け。<br>今回だと`#vegetable`。つまり、「やさい」に関するものであると示している。|
||`inert`|パネルはデフォルトでは閉じている状態なので、この属性を付与することでフォーカス関連のイベントや支援技術からのイベント、ユーザーから送られる入力イベントもブラウザが無視するようになる。<br>`aria-hidden="true"`や`display:none;`でも同じことができるが、今回はアニメーションもさせたかったのでこちらを選択。|
||`class="..."`|Tailwind CSSでスタイル調整。|
|`<ul>`<br>`<li>`<br>`<a>`||フォーカスできれば何でもよかったので、リンクをリストで並べてスタイル調整しただけのもの。<br>今回は動作確認のためにフォーカス可能な要素にしたが、フォーカスできない要素でもOK。|

## Accordion Controller

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

```html: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));`に変化することで、意図したアニメーションになってくれます。

```html: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>
```

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

https://coliss.com/articles/build-websites/operation/css/css-transition-from-height-0-to-auto.html

### Targets

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

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

```html: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`属性を付与します。

```html: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`をトグルさせます。

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

```js: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', '');
        }
    }
});
```

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

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

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

|キーボード操作|説明|
|---|---|
|<kbd>Enter</kbd> or <kbd>Space</kbd>|フォーカスが折りたたまれたパネルのヘッダーにあるとき、対応するパネルを開く。もし仕様により一つのパネルだけ開くことを許していて、他のパネルが開かれるた場合、そのパネルを閉じる。<br><br>フォーカスが開いたパネルのヘッダーにあるとき、もし仕様が閉じることをサポートしていた場合、そのパネルを閉じる。<br><br>いくつかの仕様では、常に一つのパネルが開かれていることを要求し、一つのパネルだけが開くことを許可し、それらは閉じる機能をサポートしない。|
|<kbd>Tab</kbd>|フォーカスを次のフォーカス可能な要素に移動する。アコーディオンの全てのフォーカス可能な要素は、ページのタブ・シーケンスの中に含まれる。|
|<kbd>Shift + Tab</kbd>|<kbd>Tab</kbd>の逆。|

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

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

昔は見た記憶があるのですが、今ではそれぞれに開閉機能があり、全部開くことも全部閉じることも可能なアコーディオンが主流だと思います。[公式のサンプル](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/examples/accordion/)もそうなってますしね。

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

### オプション（任意）扱いのキーボード・インタラクション

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

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

### Actions (Keyboard Event)

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

```html: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`で取得して、それらは配列で取れるので、イベントが発生した要素のインデックス番号を取得して、次に移動するか前に移動するか、最初に移動するか最後に移動するか、を制御するだけです。

```js: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();
    }
});
```

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

<p class="codepen" data-height="500" data-default-tab="html,result" data-slug-hash="VwJBvye" data-pen-title="QIITA_STIMULUS_ACCESIBLE_ACCORDION_1" data-user="yoruaki" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/yoruaki/pen/VwJBvye">
  QIITA_STIMULUS_ACCESIBLE_ACCORDION_1</a> by Yoruaki (<a href="https://codepen.io/yoruaki">@yoruaki</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>

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

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

今回[APGのAccordion Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/)に沿って実装しつつ記事を書いていたため、最初は気づかなかったのですが、今だったら`details`要素と`summary`要素使うよなぁ、と思いましたが後の祭り😇

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

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

<p class="codepen" data-height="500" data-default-tab="html,result" data-slug-hash="vYqVXyX" data-pen-title="QIITA_STIMULUS_ACCESIBLE_ACCORDION_2" data-user="yoruaki" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/yoruaki/pen/vYqVXyX">
  QIITA_STIMULUS_ACCESIBLE_ACCORDION_2</a> by Yoruaki (<a href="https://codepen.io/yoruaki">@yoruaki</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>

## 最後に

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

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

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

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