実践Webアクセシビリティ

アクセシブルなカルーセルの実装:キーボード操作、ARIA、アニメーション制御

Tags: アクセシビリティ, カルーセル, ARIA, キーボード操作, JavaScript, WCAG

ウェブサイトで多くの情報をコンパクトに表示するためにカルーセル(スライドショー)がよく利用されます。しかし、カルーセルはその実装によっては、キーボードユーザー、スクリーンリーダーユーザー、認知特性を持つユーザーなど、多様なユーザーにとって情報へのアクセスや操作が困難になる場合があります。

この記事では、アクセシブルなカルーセルの具体的な実装方法について、HTML、CSS、JavaScriptを用いたコード例を交えながら解説します。

なぜカルーセルにアクセシビリティ対応が必要か

カルーセルは視覚的な情報伝達に優れていますが、アクセシビリティの観点からは以下のような課題があります。

これらの課題を解決し、全てのユーザーがカルーセルによって提示される情報に円滑にアクセスし、操作できるよう、アクセシビリティを考慮した実装が不可欠です。

具体的な実装手順

アクセシブルなカルーセルを実装するためには、主にHTMLのセマンティクスとARIA属性、CSSによる表示制御、JavaScriptによる動的な制御(キーボード操作、ARIA属性の更新、アニメーション制御)を組み合わせる必要があります。

1. 基本的なHTML構造とARIA属性

カルーセルの構造をセマンティックにマークアップし、ARIA属性でその役割や状態を補足します。

<div class="carousel" role="region" aria-label="特集記事">
  <div class="carousel__items">
    <div class="carousel__item" role="group" aria-label="1 of 3">
      <!-- スライドコンテンツ (画像、見出し、テキスト、リンクなど) -->
      <img src="image1.jpg" alt="特集記事1の画像">
      <h3>特集記事1</h3>
      <p>記事の簡単な説明。</p>
      <a href="#">詳細を読む</a>
    </div>
    <div class="carousel__item" role="group" aria-label="2 of 3" aria-hidden="true">
      <!-- スライドコンテンツ -->
      <img src="image2.jpg" alt="特集記事2の画像">
      <h3>特集記事2</h3>
      <p>記事の簡単な説明。</p>
      <a href="#">詳細を読む</a>
    </div>
    <div class="carousel__item" role="group" aria-label="3 of 3" aria-hidden="true">
      <!-- スライドコンテンツ -->
      <img src="image3.jpg" alt="特集記事3の画像">
      <h3>特集記事3</h3>
      <p>記事の簡単な説明。</p>
      <a href="#">詳細を読む</a>
    </div>
  </div>

  <div class="carousel__controls">
    <button class="carousel__prev" aria-label="前のスライドへ">
      <svg>...</svg> <!-- 矢印アイコンなど -->
    </button>
    <button class="carousel__next" aria-label="次のスライドへ">
      <svg>...</svg> <!-- 矢印アイコンなど -->
    </button>

    <div class="carousel__indicators" role="tablist" aria-label="特集記事スライドの選択">
        <button role="tab" aria-selected="true" aria-controls="slide1" id="tab1">1</button>
        <button role="tab" aria-selected="false" aria-controls="slide2" id="tab2">2</button>
        <button role="tab" aria-selected="false" aria-controls="slide3" id="tab3">3</button>
    </div>

    <!-- 自動再生機能がある場合 -->
    <button class="carousel__pause-play" aria-label="スライドショーを一時停止">
      <svg>...</svg> <!-- 一時停止/再生アイコン -->
    </button>
  </div>
</div>

2. CSSによる表示制御

非表示スライドは完全に隠し、インタラクティブ要素が誤って操作されないようにします。

.carousel__item {
  display: none; /* 非表示のスライド */
}

.carousel__item[aria-hidden="false"] {
  display: block; /* 表示中のスライド */
}

/* 非表示要素内のインタラクティブ要素がフォーカスされないように */
.carousel__item[aria-hidden="true"] a,
.carousel__item[aria-hidden="true"] button,
.carousel__item[aria-hidden="true"] input,
.carousel__item[aria-hidden="true"] select,
.carousel__item[aria-hidden="true"] textarea {
  tabindex: -1;
}

/* prefers-reduced-motion 対応 */
@media (prefers-reduced-motion: reduce) {
  .carousel__items {
    /* アニメーションを無効化または単純化 */
    scroll-behavior: auto; /* 例: スクロールアニメーションを無効に */
  }
  /* その他、transition や animation プロパティの使用を控えるか、単純なものに変更 */
}

3. JavaScriptによる動的な制御

キーボード操作への対応、ARIA属性の動的な更新、アニメーション制御などを実装します。

class AccessibleCarousel {
  constructor(element) {
    this.carousel = element;
    this.items = this.carousel.querySelectorAll('.carousel__item');
    this.prevButton = this.carousel.querySelector('.carousel__prev');
    this.nextButton = this.carousel.querySelector('.carousel__next');
    this.indicators = this.carousel.querySelectorAll('.carousel__indicators button');
    this.pausePlayButton = this.carousel.querySelector('.carousel__pause-play');

    this.currentIndex = 0; // 現在表示中のスライドのインデックス

    this.init();
  }

  init() {
    // 初期状態を設定
    this.updateCarouselState();
    this.setupEventListeners();
    this.setupKeyboardNavigation();

    // 自動再生機能があれば初期設定
    if (this.pausePlayButton) {
      this.isPaused = false; // 自動再生中か
      this.autoPlayInterval = 5000; // 例: 5秒ごとに切り替え
      this.startAutoPlay();
    }
  }

  updateCarouselState() {
    this.items.forEach((item, index) => {
      const isCurrent = index === this.currentIndex;
      item.setAttribute('aria-hidden', !isCurrent);

      // 表示中のスライド内のインタラクティブ要素にtabindex="0"を設定
      // 非表示のスライド内の要素にはtabindex="-1"を設定 (CSSでも設定済みだが念のため)
      const focusableElements = item.querySelectorAll('a, button, input, select, textarea');
      focusableElements.forEach(el => {
        el.setAttribute('tabindex', isCurrent ? '0' : '-1');
      });

      // スライドの位置情報をaria-labelで更新
      item.setAttribute('aria-label', `${index + 1} of ${this.items.length}`);
    });

    // インジケーターの状態を更新
    this.indicators.forEach((indicator, index) => {
      const isSelected = index === this.currentIndex;
      indicator.setAttribute('aria-selected', isSelected);
      // 現在のインジケーターにフォーカスを当てる設定も考慮するが、今回はシンプルに属性更新のみ
    });

    // ナビゲーションボタンの有効/無効状態を設定 (ループしない場合)
    // 例: this.prevButton.disabled = this.currentIndex === 0;
    // 例: this.nextButton.disabled = this.currentIndex === this.items.length - 1;
  }

  showSlide(index) {
    // インデックスの範囲をチェック (ループさせるかなどで調整)
    if (index < 0) {
        // this.currentIndex = this.items.length - 1; // 最後に戻る (ループ)
        index = 0; // 最初のまま (ループしない)
    } else if (index >= this.items.length) {
        // this.currentIndex = 0; // 最初に戻る (ループ)
        index = this.items.length - 1; // 最後のまま (ループしない)
    }
    this.currentIndex = index; // 実際にはスムーズな切り替え処理などが入る

    this.updateCarouselState();
  }

  setupEventListeners() {
    this.prevButton.addEventListener('click', () => this.showSlide(this.currentIndex - 1));
    this.nextButton.addEventListener('click', () => this.showSlide(this.currentIndex + 1));

    this.indicators.forEach((indicator, index) => {
        indicator.addEventListener('click', () => this.showSlide(index));
    });

    if (this.pausePlayButton) {
        this.pausePlayButton.addEventListener('click', () => {
            if (this.isPaused) {
                this.startAutoPlay();
                this.pausePlayButton.setAttribute('aria-label', 'スライドショーを一時停止');
            } else {
                this.pauseAutoPlay();
                this.pausePlayButton.setAttribute('aria-label', 'スライドショーを再生');
            }
            this.isPaused = !this.isPaused;
        });
    }

    // 自動再生中にカルーセルまたはその内部要素にフォーカスが当たったら一時停止
    this.carousel.addEventListener('focusin', () => {
        if (!this.isPaused) { // ユーザー自身が一時停止してない場合のみ
            this.pauseAutoPlay();
            // 一時停止ボタンの状態も視覚的に更新するなどの考慮が必要
        }
    });
    // フォーカスが外れたら再生再開 (任意の実装)
    this.carousel.addEventListener('focusout', () => {
        // ここで再生を再開するかは議論の余地あり。
        // ユーザーが明示的に再生しない限り停止したままの方がアクセシブルな場合も多い。
        // 例えば、ユーザーが一時停止ボタンを押していなかった場合のみ再開するなど。
    });
  }

  setupKeyboardNavigation() {
    // カルーセル全体へのキーボード操作(矢印キーなど)
    this.carousel.addEventListener('keydown', (event) => {
      switch (event.key) {
        case 'ArrowLeft': // 左矢印
          event.preventDefault(); // デフォルトのスクロールなどを防ぐ
          this.showSlide(this.currentIndex - 1);
          break;
        case 'ArrowRight': // 右矢印
          event.preventDefault();
          this.showSlide(this.currentIndex + 1);
          break;
        case 'Home': // Homeキーで最初のスライドへ (任意)
          event.preventDefault();
          this.showSlide(0);
          break;
        case 'End': // Endキーで最後のスライドへ (任意)
          event.preventDefault();
          this.showSlide(this.items.length - 1);
          break;
        // Tabキーの制御はCSSとtabindex属性の切り替えで行う
      }
    });

    // インジケーター間での矢印キー移動 (Tablist/Tab ロールを使用した場合)
    this.indicators.forEach((indicator, index) => {
        indicator.addEventListener('keydown', (event) => {
            let nextIndex = -1;
            switch (event.key) {
                case 'ArrowLeft':
                    nextIndex = index - 1 < 0 ? this.indicators.length - 1 : index - 1;
                    break;
                case 'ArrowRight':
                    nextIndex = index + 1 >= this.indicators.length ? 0 : index + 1;
                    break;
                case 'Home':
                    nextIndex = 0;
                    break;
                case 'End':
                    nextIndex = this.indicators.length - 1;
                    break;
                case 'Enter': // Enterキーでスライドを選択
                case ' ': // Spaceキーでスライドを選択
                    event.preventDefault(); // デフォルトのスクロールなどを防ぐ
                    this.showSlide(index);
                    return; // スライド切り替え後はフォーカス移動させない
            }

            if (nextIndex !== -1) {
                event.preventDefault();
                this.indicators[nextIndex].focus(); // 次のインジケーターにフォーカスを移動
                // focus()だけではスライドは切り替わらない点に注意。
                // インジケーターにフォーカスが当たっただけでスライドを切り替えるか、
                // Enter/Spaceで切り替えるか、仕様による。WCAG的にはTablist/Tabのパターンが多い。
            }
        });
    });
  }

  // 自動再生関連メソッド
  startAutoPlay() {
      if (this.autoPlayTimer) this.pauseAutoPlay(); // 既存タイマーをクリア
      this.autoPlayTimer = setInterval(() => {
          this.showSlide(this.currentIndex + 1); // 次のスライドへ
      }, this.autoPlayInterval);
      // this.pausePlayButton の表示を「一時停止」にするなどの視覚的更新
  }

  pauseAutoPlay() {
      clearInterval(this.autoPlayTimer);
      this.autoPlayTimer = null;
      // this.pausePlayButton の表示を「再生」にするなどの視覚的更新
  }
}

// カルーセルを初期化
document.querySelectorAll('.carousel').forEach(carouselElement => {
  new AccessibleCarousel(carouselElement);
});

4. その他の考慮事項

実装したアクセシビリティ対応のテスト方法

実装後には、必ず以下の方法でテストを実施し、意図した通りに動作するか確認します。

まとめ

アクセシブルなカルーセルの実装は、単に要素を配置するだけでなく、キーボード操作への対応、ARIA属性による意味付け、そして自動再生などの制御機能の実装が不可欠です。特に、表示中のコンテンツのみがインタラクティブになり、スクリーンリーダーに正しく状態が伝わるようにJavaScriptで動的に制御する部分が重要になります。

この記事で紹介した基本的な実装方法とテスト手順を参考に、全てのユーザーにとって使いやすいカルーセルを目指してください。アクセシビリティは一度対応すれば終わりではなく、継続的な改善が求められる取り組みです。