Stimulusでa11yを考慮した可変のカルーセルを実装する

カルーセルのアクセシビリティを考慮しつつ、Stimulusで実装してみたいと思います。

## カルーセルとは

> 文字通り訳すと「回転木馬」という意味ですが、情報 (画像だったり、画像＋テキストだったり) をパネル状に横に並べて、Web ページの幅を超えて隠れている情報も「くるくる」と手繰り寄せて表示できる、という UI です。

via: [カルーセル | Accessible &amp; Usable](https://accessible-usable.net/2014/03/entry_140316.html)

----

「回転木馬」は、「メリーゴーラウンド」と言えば分かりやすいですかね。
ウェブサイト上でくるくる回るアレです。
自動でスライドするかどうかや、無限にループするかどうかは、仕様によります。

### 可変のカルーセルとは

表示領域が固定されているカルーセルはよくありますが、今回はこの表示領域がコンテンツ幅によって可変するカルーセルを実装してみたいと思います。
具体的には以下のようなものです。

<p class="codepen" data-height="500" data-default-tab="result" data-slug-hash="RwEpdxQ" 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/RwEpdxQ">
  QIITA_STIMULUS_ACCESIBLE_VARIABLE_CAROUSEL</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
  role="group"
  aria-label="猫の写真"
  aria-roledescription="カルーセル"
>
  <div>
    <button type="button" aria-controls="carouselList">前</button>
    <button type="button" aria-controls="carouselList">次</button>
  </div>
  <ul
    role="none"
    aria-live="polite"
    aria-atomic="false"
    id="carouselList"
  >
    <li
      role="group"
      aria-label="3枚中1枚"
      aria-roledescription="スライド"
    >
      <a href="hoge">
        <img src="hoge.png" alt="">
        <span>hoge猫</span>
      </a>
    </li>
    <li
      role="group"
      aria-label="3枚中2枚"
      aria-roledescription="スライド"
      aria-hidden="true"
    >
      <a href="fuga" tabindex="-1">
        <img src="fuga.png" alt="">
        <span>fuga猫</span>
      </a>
    </li>
    <li
      role="group"
      aria-label="3枚中3枚"
      aria-roledescription="スライド"
      aria-hidden="true"
    >
      <a href="piyo" tabindex="-1">
        <img src="piyo.png" alt="">
        <span>piyo猫</span>
      </a>
    </li>
  </ul>
</div>
```

※装飾のためのclass属性などは一旦省略

### 要素の説明

|要素|属性|説明|
|---|---|---|
|<code>&lt;div&gt;</code>||全体のラッパー。
||<code>role="group"</code>|ページ送りのボタン含む全ての要素がカルーセルの一部ということを示す。|
||<code>aria-label="猫の写真"</code>|何についてのカルーセルなのかを伝える。<br>今回は猫の写真のカルーセルであることを示している。|
||<code>aria-roledescription="カルーセル"</code>|このリストの具体的な役割をスクリーンリーダーに伝える。<br>今回はカルーセルであることを示している。|
|<code>&lt;div&gt;</code>||ページ送りボタンのラッパー。
|<code>&lt;button&gt;|<code>type="button"</code>|ボタン。|
||<code>aria-controls="carouselList"</code>|このボタンは何を制御しているのか。<br>今回はカルーセル本体である<code>#carouselList</code>。|
|<code>&lt;ul&gt;</code>|<code>role="none"</code>|子要素である<code>li</code>要素に<code>role="group"</code>を付与するため。|
||<code>aria-live="polite"</code>|要素に何か変更があったときに、変更内容をスクリーンリーダーなどの支援技術に伝える。<br>今回はボタンを押したことにより「どの猫の画像が表示されているか」を伝えたい。|
||<code>aria-atomic="true"</code>|要素に何か変更があったときに、変更箇所だけを読み上げるか（<code>false</code>）、全体を読み上げるか（<code>true</code>）。<br>今回は変更箇所だけを読み上げてほしかったので<code>false</code>を指定。|
||<code>id="carouselList"</code>|ページ送りボタンの制御先として指定。|
|<code>&lt;li&gt;</code>|<code>role="group"</code>|スライド。正直<code>li</code>要素じゃなくてもよかったかも。|
||<code>aria-label="3枚中1枚"</code>|今表示されているスライドが、何枚中何枚目なのかを伝える。|
||<code>aria-roledescription="スライド"</code>|この要素の具体的な役割をスクリーンリーダーに伝える。<br>今回はスライドであることを示している。|
||<code>aria-hidden="true"|カルーセルの特徴的に、表示領域内以外のスライドはアクセシビリティツリー上から削除。|
|<code>&lt;a&gt;</code>|<code>href="hoge"</code>|リンク。|
||<code>tabindex="-1"</code>|ページ送りボタンがあるので、アクティブなリンク以外のリンクはフォーカスしないように設定。|
|<code>&lt;img&gt;</code>|<code>src="hoge.png"</code>|画像。|
||<code>alt=""</code>|代替テキスト。<br>リンク内画像だけど、今回は別でテキストがあるので空を指定。|
|<code>&lt;span&gt;</code>||キャプション。|

以上で意味的にはアクセシブルなカルーセルの完成です。
これを見て思うことは、ボタンでわざわざフォーカスを変えなきゃいけないだけの、ただの画像リンクリストなんですよね。
どちらも同等の情報取得ができるように、Stimulusで振る舞いをアタッチしていきます。

## Carousel Controller

今回与えたい振る舞いは大まかに、ボタンをクリックすることで次もしくは前の画像に切り替わる（スライドするなどの動作はCSSで制御）こと、一番最後の画像で次ボタンをクリックしたら、一番最初の画像に切り替わること、一番最初の画像で前ボタンをクリックしたら、一番最後の画像に切り替わることです。

横並びにした画像の表示領域やアニメーションなどの見た目は全てCSSで制御するとして、機能としては最低限これだけでOKです。それ以外の機能は「仕様による」といった具合です。

まずは全体のラッパーに`data-controller="carousel"`を付与して、Carousel Controllerを作ります。

```html
<div
  role="group"
  aria-label="猫の写真"
  aria-roledescription="カルーセル"
  data-controller="carousel"
>
  <div>
    <button type="button" aria-controls="carouselList">前</button>
    <button type="button" aria-controls="carouselList">次</button>
  </div>
  .
  .
  .
</div>
```

次に取得したい要素に<code>data-carousel-target</code>属性を付与します。
今回はカルーセル本体（<code>ul</code>要素）、スライド（<code>li</code>要素）、スライド内のリンクです。

```html:カルーセル本体
<ul
  role="none"
  aria-live="polite"
  aria-atomic="false"
  id="carouselList"
  data-carousel-target="catList"
>
```

```html:スライドとスライド内のリンク
<li
  role="group"
  aria-label="3枚中1枚"
  aria-roledescription="スライド"
  data-carousel-target="slide"
>
  <a href="hoge">
    <img src="hoge.png" alt="">
    <span>hoge猫</span>
  </a>
</li>
<li
  role="group"
  aria-label="3枚中2枚"
  aria-roledescription="スライド"
  data-carousel-target="slide"
  aria-hidden="true"
>
  <a href="fuga" tabindex="-1" data-carousel-target="link">
    <img src="fuga.png" alt="">
    <span>fuga猫</span>
  </a>
</li>
<li
  role="group"
  aria-label="3枚中3枚"
  aria-roledescription="スライド"
  data-carousel-target="slide"
  aria-hidden="true"
>
  <a href="piyo" tabindex="-1" data-carousel-target="link">
    <img src="piyo.png" alt="">
    <span>piyo猫</span>
  </a>
</li>
```

## 今回の可変カルーセルの要件

* 「次」ボタンをクリックしたら、次の（右の）スライドを表示
* 最後のスライドで「次」ボタンをクリックしたら、最初のスライドを表示
* 「前」ボタンをクリックしたら、前の（左の）スライドを表示
* 最初のスライドで「前」ボタンをクリックしたら、最後のスライドを表示
* アクティブなスライドは**左右中央に表示**

要件は以上の5点です。
キーボード操作は特にありません。

次ボタンと前ボタンは左右キーで移動できた方がいいんじゃないか、などと考えたのですが、**カルーセルは操作方法をOSから輸入できていないウェブ特有のUIのため、キーボード操作を実装してもユーザーが気づかない可能性**があります。

### 「次」ボタンの挙動

まずは<q>「次」ボタンをクリックしたら、次の（右の）スライドを表示</q>を実装してみます。
HTMLはどちらも似たようなものなので、「前」ボタンも一緒にやってみます。

```html:「次」ボタンと「前」ボタン
<div
  role="group"
  aria-label="猫の写真"
  aria-roledescription="カルーセル"
  data-controller="carousel"
>
  <div>
    <button
      type="button"
      aria-controls="carouselList"
      data-action="click->carousel#prevSlide"
    >
      前
    </button>
    <button
      type="button"
      aria-controls="carouselList"
      data-action="click->carousel#nextSlide"
    >
      次
    </button>
  </div>
  .
  .
  .
</div>
```

「次」ボタンなので「nextSlide」、「前」ボタンなので「prevSlide」です。
nextSlideの中身を見てみます。

```js:「次」ボタンの挙動
nextSlide() {
  const slides = this.slideTargets;
  let catImageSize = 0;

  if (this.slideIndexValue === slides.length) {
    this.slideIndexValue = 0;
  }

  for (let i = 0; i <= this.slideIndexValue; i++) {
    catImageSize = catImageSize + slides[i].clientWidth;
  }

  catImageSize = catImageSize - slides[this.slideIndexValue].clientWidth / 2;
  this.moveSlide(catImageSize);
  this.activeSlide(this.slideIndexValue);
  this.slideIndexValue++;
}
```

内容の詳細は割愛しますが、簡単に説明すると、
1. クリック時に表示されている画像がスライドの最後だったら、最初のスライドに戻る
2. それ以外は全体を右に移動（マイナスマージン）
3. 以降1.から繰り返し

「前」ボタンはその逆です。
やってることは至ってシンプル。

### アクティブなスライドは左右中央に表示

まず表示させたい画像を横幅の半分（50vw）に移動させて、さらにそこからその画像の半分（clientWidth / 2）をずらして実現しています。
さらにアクティブなスライド**以外**のスライドには、`aria-hidden="true`と`tabindex="-1"`を付与して、タブのフォーカスがいかないようにしています。

（2024/02/26 16:30追記）

`aria-hidden`と`tabindex`の制御は、`inert`属性使うと楽だよとアドバイスいただきました。
`inert`属性を付与すると、アクセシビリティツリーから消えるだけじゃなく、その中のフォーカス可能な要素もフォーカス不能になるそうです。お得！
[inert - HTML: ハイパーテキストマークアップ言語 | MDN](https://developer.mozilla.org/ja/docs/Web/HTML/Global_attributes/inert)



## 最後に

アクセシビリティを考慮したカルーセルをStimulusで実装してみました。
既存のコードなどを見てみると、一瞬複雑そうに見えますが、一つ一つ紐解いてみると理解が深まりますね。

#### 無限ループについて

上記で〆ようと思ったのですが、やっぱりカルーセルと言ったら無限にループするのを連想しますよね。最初のスライドの前に最後のスライドが、最後のスライドの次に最初のスライドが来てるやつ。

自力で実装しようと思ったのですが、正直やり方が全然思いつきませんでした（個人で実装を請け負った時に要件に入ってた場合、素直に[SwiperJS](https://swiperjs.com)使わせてもらってます）。
ので、ChatGPTに聞いてみたり、「カルーセル 無限ループ」などで検索したのですが、イマイチよく分かりませんでした。

この記事を公開したあと、社内の優秀なフロントエンドエンジニアの方たちに聞いてみたいと思います。後日良い実装方法が分かったら追記するかもしれません。