🐶

【挑戦】Swiper を使ったカルーセルのアクセシビリティ対応【a11y】

に公開

はじめに

Splide はアクセシビリティの対応ができているライブラリで知られていますが
最終更新が2022年9月14日と随分と時間が経っていることから
安易に導入するのが悩ましいところでした。

それに対して、Swiper は更新が続いていることから
できることであれば、Splide から Swiper に移行したいと考えていました。

https://github.com/Splidejs/splide

https://github.com/nolimits4web/swiper

前提

今回利用する Swiper のバージョンは 12.0.2 です。

Vue や React などのフレームワークは使用せず
HTML、CSS、JS のみで実装しています。

問題点

Swiper にはアクセシビリティ対応のためのモジュールがありますが、以下の問題があります。

適切な role が設定されていない

要素がインタラクティブな単位で role が設定されておらず不十分さを感じました。
role="group"aria-roledescription 属性を使用して
目的を正確に説明し、スクリーンリーダーのユーザーに提供すべき。

スライド変更時のリアルタイムアナウンスがない

  • スクリーンリーダーのユーザーが表示されているスライドに変更があったことがわからない
  • アクセシブルな名前・役割・値の更新はライブリージョンを通じて伝達されることを要求する WCAG 4.1.2 に違反 ※

※ ライブリージョンとは
画面上の変更をスクリーンリーダーがリアルタイムで読み上げる機能

タッチできる要素のサイズが小さすぎる

24x24px 未満と小さすぎるので、WCAG 2.5.8 ターゲットサイズに違反

対応内容

  • role="group" のロールを明確にするため aria-roledescription を追加
  • スライドの読み上げを有効化するため、aria-live="off" を削除
  • 読み上げ用の要素を追加
  • ページネーションを読み上げを実装
  • ページャーを読み上げを実装
  • 再生ボタンを読み上げを実装

実装

デモは以下の StackBlitz から確認できます。
実装の解説については、コード上にコメントを記載しています。

HTML

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/swiper@12/swiper-bundle.min.css"
    />
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <div
      class="c-carousel swiper js-carousel"
      id="slider1"
      role="group"
      aria-roledescription="スライダー"
      aria-label="記事のスライダー"
    >
      <div class="c-carousel__slides swiper-wrapper">
        <div
          class="c-carousel__slide swiper-slide"
          aria-roledescription="スライド"
        >
          <a href="#">記事 その1</a>
        </div>
        <div
          class="c-carousel__slide swiper-slide"
          aria-roledescription="スライド"
        >
          <a href="#">記事 その2</a>
        </div>
        <div
          class="c-carousel__slide swiper-slide"
          aria-roledescription="スライド"
        >
          <a href="#">記事 その3</a>
        </div>
        <!-- 省略 -->
      </div>
      <div
        class="c-carousel__navigation"
        role="group"
        aria-roledescription="ナビゲーション"
        aria-label="ナビゲーション"
      >
        <button class="swiper-button-prev js-carousel-previous"></button>
        <button class="swiper-button-next js-carousel-next"></button>
      </div>
      <div class="c-carousel__controls">
        <div
          class="c-carousel__pagination swiper-pagination js-carousel-pagination"
          role="group"
          aria-roledescription="ページネーション"
          aria-label="ページネーション"
        ></div>
        <div
          class="c-carousel__autoplay"
          role="group"
          aria-roledescription="再生・停止ボタン"
          aria-label="再生・停止ボタン"
        >
          <button
            class="c-carousel__autoplay-button js-carousel-autoplay-button"
            data-status="start"
          >
            停止
          </button>
        </div>
      </div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/swiper@12/swiper-bundle.min.js"></script>
    <script src="script.js"></script>
    <script>
      const { initCarousel } = useCarousel();
      document.addEventListener('DOMContentLoaded', () => {
        initCarousel();
      });
    </script>
  </body>
</html>

JavaScript

const useCarousel = () => {
  // カルーセルのクラス名を定義
  const carouselClassName = '.js-carousel';
  // カルーセルインスタンスを管理するMapオブジェクト
  const carouselInstances = new Map();

  /**
   * カルーセルインスタンスを作成する関数
   * @param {HTMLElement} element - カルーセルのDOM要素
   * @returns {Object} カルーセルインスタンスオブジェクト
   */
  const createCarouselInstance = (element) => {
    // スクリーンリーダー用のライブリージョンを作成
    const liveRegion = document.createElement('div');

    // 各種ボタンとコンテナ要素を取得
    const autoplayButton = element.querySelector(
      '.js-carousel-autoplay-button'
    );
    const paginationContainer = element.querySelector(
      '.js-carousel-pagination'
    );
    const previousButton = element.querySelector('.js-carousel-previous');
    const nextButton = element.querySelector('.js-carousel-next');

    // Swiperインスタンスを作成・設定
    const swiper = new Swiper(element, {
      direction: 'horizontal', // 水平方向のスライド
      slidesPerView: 'auto', // スライドの表示数を自動調整
      spaceBetween: 8, // スライド間の余白(デフォルト)
      loop: true, // 無限ループを有効化

      // ナビゲーションボタンの設定
      navigation: {
        nextEl: '.swiper-button-next',
        prevEl: '.swiper-button-prev',
      },

      // 自動再生の設定
      autoplay: {
        delay: 3e3, // 3秒間隔で自動再生
        disableOnInteraction: false, // ユーザー操作後も自動再生を継続
      },

      // ページネーションの設定
      pagination: {
        el: paginationContainer,
        bulletElement: 'button', // ページネーションをボタン要素で作成
        clickable: true, // クリック可能にする
      },

      // アクセシビリティ設定
      a11y: {
        prevSlideMessage: '前のスライドへ',
        nextSlideMessage: '次のスライドへ',
        slideLabelMessage: '{{index}}枚目',
        paginationBulletMessage: '{{index}}枚目のスライドを表示',
      },
    });

    return {
      swiper,
      liveRegion,
      autoplayButton,
      paginationContainer,
      previousButton,
      nextButton,
    };
  };

  /**
   * カルーセルを初期化する関数
   * ページ内の全てのカルーセル要素を検索し、それぞれにインスタンスを作成
   */
  const initCarousel = () => {
    const carouselElements = document.querySelectorAll(carouselClassName);

    carouselElements.forEach((element, index) => {
      // カルーセルのIDを取得(なければ自動生成)
      const carouselId = element.id || `carousel-${index}`;

      // カルーセルインスタンスを作成
      const instance = createCarouselInstance(element);

      // インスタンスをMapに保存(後で参照できるように)
      carouselInstances.set(carouselId, instance);

      // 各種機能を初期化
      initAutoplayButton(instance); // 自動再生ボタンの初期化
      initLiveRegion(instance, element); // ライブリージョンの初期化
      initAnnouncePagination(instance); // ページネーション音声案内の初期化
      removeCarouselAttributes(element); // 不要な属性の削除
      initAnnouncePreviousButton(instance); // 前へボタンの音声案内初期化
      initAnnounceNextButton(instance); // 次へボタンの音声案内初期化
    });
  };

  /**
   * カルーセル要素から不要な属性を削除する関数
   * @param {HTMLElement} element - カルーセル要素
   */
  const removeCarouselAttributes = (element) => {
    const wrapper = element.querySelector('.js-carousel-wrapper');
    if (wrapper === null) return;

    // Swiperが独自のaria-liveを管理するため、既存のものを削除
    wrapper.removeAttribute('aria-live');
  };

  /**
   * 自動再生の開始/停止を切り替える関数
   * @param {HTMLElement} button - 自動再生ボタン要素
   * @param {Object} instance - カルーセルインスタンス
   */
  const toggleAutoplay = (button, instance) => {
    if (instance.swiper.autoplay.running) {
      // 自動再生中の場合は停止
      instance.swiper.autoplay.stop();
      button.setAttribute('data-status', 'stop');
      button.textContent = '再生';
    } else {
      // 停止中の場合は開始
      instance.swiper.autoplay.start();
      button.setAttribute('data-status', 'start');
      button.textContent = '停止';
    }
  };

  /**
   * ライブリージョンを初期化する関数
   * スクリーンリーダー用の音声案内領域を設定
   * @param {Object} instance - カルーセルインスタンス
   * @param {HTMLElement} slideElement - スライド要素
   */
  const initLiveRegion = (instance, slideElement) => {
    instance.liveRegion.className = 'c-carousel__live-region';
    instance.liveRegion.setAttribute('aria-live', 'polite'); // 丁寧な音声案内
    instance.liveRegion.setAttribute('aria-atomic', 'true'); // 内容全体を読み上げ

    // スライド要素の直後にライブリージョンを挿入
    slideElement.insertAdjacentElement('afterend', instance.liveRegion);
  };

  /**
   * ライブリージョンにメッセージを表示し、一定時間後にクリアする関数
   * @param {string} message - 案内メッセージ
   * @param {HTMLElement} liveRegion - ライブリージョン要素
   */
  const announceMessage = (message, liveRegion) => {
    liveRegion.textContent = message;

    // 1秒後にメッセージをクリア(連続した案内を防ぐため)
    setTimeout(() => {
      liveRegion.textContent = '';
    }, 1e3);
  };

  /**
   * ページネーションボタンクリック時の音声案内を初期化する関数
   * @param {Object} instance - カルーセルインスタンス
   */
  const initAnnouncePagination = (instance) => {
    const paginationContainer = instance.paginationContainer;
    if (paginationContainer) {
      paginationContainer.addEventListener('click', (event) => {
        const button = event.target;

        // クリックされた要素がボタンの場合のみ処理
        if (button.tagName === 'BUTTON') {
          // ボタンのインデックスを取得
          const buttons = Array.from(
            paginationContainer.querySelectorAll('button')
          );
          const index = buttons.indexOf(button);

          // スライド番号を音声案内
          announceMessage(
            `${index + 1}枚目のスライドを表示`,
            instance.liveRegion
          );
        }
      });
    }
  };

  /**
   * 自動再生ボタンの動作を初期化する関数
   * @param {Object} instance - カルーセルインスタンス
   */
  const initAutoplayButton = (instance) => {
    const button = instance.autoplayButton;
    if (button === null) return;

    button.addEventListener('click', (event) => {
      const button2 = event.target;

      // 自動再生の状態を切り替え
      toggleAutoplay(button2, instance);

      // 切り替え後の状態に応じて音声案内
      if (instance.swiper.autoplay.running) {
        announceMessage('自動再生を停止しました', instance.liveRegion);
      } else {
        announceMessage('自動再生を開始しました', instance.liveRegion);
      }
    });
  };

  /**
   * 前へボタンクリック時の音声案内を初期化する関数
   * @param {Object} instance - カルーセルインスタンス
   */
  const initAnnouncePreviousButton = (instance) => {
    const previousButton = instance.previousButton;
    if (previousButton === null) return;

    previousButton.addEventListener('click', () => {
      announceMessage('前のスライドへ', instance.liveRegion);
    });
  };

  /**
   * 次へボタンクリック時の音声案内を初期化する関数
   * @param {Object} instance - カルーセルインスタンス
   */
  const initAnnounceNextButton = (instance) => {
    const nextButton = instance.nextButton;
    if (nextButton === null) return;

    nextButton.addEventListener('click', () => {
      announceMessage('次のスライドへ', instance.liveRegion);
    });
  };

  return {
    initCarousel,
  };
};

CSS

.c-carousel {
  position: relative;
}
.c-carousel .swiper-slide {
  width: 353px;
  height: 300px;
  padding: 16px;
  background-color: #ccc;
}
.c-carousel .swiper-pagination {
  position: relative;
  top: auto;
  bottom: auto;
  left: auto;
  display: flex;
  gap: 8px;
  width: auto;
  margin: 0;
}
.c-carousel .swiper-pagination-bullet {
  margin: 0;
  width: 24px;
  height: 24px;
}
.c-carousel__controls {
  display: flex;
  gap: 8px;
  align-items: center;
  margin-top: 8px;
}
.c-carousel__live-region {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  white-space: nowrap;
  clip: rect(1px, 1px, 1px, 1px);
  -webkit-clip-path: inset(100%);
  clip-path: inset(100%);
}

おわりに

今回の Swiper を Splide により近づけることを試みたことで
アクセシビリティ対応に関して、スキルアップの良い機会となりました。

スクリーンリーダーによる読み上げに関しては
今まではなんとなく実装していたのですが、より深く理解することができました。

また、ライブリージョンの実装に関しては
これまで未知の領域だったため、実装方法を習得できたのは大きな収穫となりました。

今回の実装で Swiper が Splide の代替となる道筋ができたので
積極的に利用していきたいと考えています。

本記事を読んでいただき、ありがとうございました!!

株式会社ソニックムーブ

Discussion