【アクセシビリティ】アクセシビリティを意識したカルーセルの作り方

## はじめに
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/390720/c6d6cff3-d8c1-b73e-e062-6e495569e0d2.png)

みなさんアクセシビリティを意識して開発できていますか？

必要なところに`role属性`を記述したり、`tabキー`でフォーカスができるようにしたりなど、意識しないといけないことも多いです。
そのため、アクセシビリティを完璧にやろうとするのは一苦労です。

ただ、コンポーネントごとに区切って、アクセシビリティを理解しておけば、実装するタイミングに思い出しやすく、アクセシビリティも意識しやすいと思います。

そのため、この記事では「**カルーセル**」に焦点を当てて、アクセシビリティを意識したカルーセルの実装方法とカルーセルで意識した方がいいアクセシビリティを解説しようと思います。

## アクセシビリティを意識したカルーセルの仕様
### ⚪︎ カルーセルとは？
カルーセルは、複数のスライドを順番に表示することができる要素です。
通常一度に１つのスライドが表示され、ユーザーが現在のスライドを非表示にして、次のスライドを表示する機能があります。
また、カルーセルのすべての要素にユーザーがアクセスできるようにするのも重要です。

カルーセルでは、以下のような機能が必要です。

+ **前と次のスライドを表示させるボタン**
+ **特定のスライドを表示させるコントロールグループ** (任意)
+ **カルーセルが自動スライドである場合は以下も必要**
  + 回転を停止・再開させるボタンをつける
  + キーボードフォーカスがカルーセルに入ると回転を停止させる
  + マウスがカルーセル上にある時は、回転を停止させる


また、カルーセルは以下の5つに部分に分かれています。

+ **Slide**
  + カルーセルで表示させる要素のコンテナー
+ **Rotation Control**
  + スライドが自動で動くのを停止・再開させる要素
+ **Next Slide Control**
  + 次のスライドへ切り替える要素
+ **Previous Slide Control**
  + 前のスライドへ切り替える要素
+ **Slide Picker Controls**
  + 特定のスライドへ切り替える要素

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/390720/f7c3e6c0-2df8-d050-2f06-75be2825abf2.png)


### ⚪︎ キーボードインタラクション

+ **キーボードフォーカス**
  + カルーセルに自動スライドである場合
    + → 自動スライドを停止する
+ **`Tabキー`・`Shift + Tabキー`**
  + ページタブシーケンスによって指定されたカルーセルのインタラクティブ要素を通してフォーカスを移動させる
+ **ボタン要素**
  + ボタンパターンで定義されたキーボード操作
  + `Next Slide Control` と `Previous Slide Control` はアクティブにしても次のフォーカスに移動させない
+ **Rotation Control**
  + Rotation Controlがある場合
    + → Rotation Controlが最初にタブフォーカスが当たる
+ **Tab要素**
  + Slide Picker ControlsにTab要素が使われている場合
    + → Tab要素で定義されたキーボード操作

### ⚪︎ WAI-ARIA の役割、状態、プロパティ
以下の３つのカルーセルの WAI-ARIA の役割、状態、プロパティについて紹介します。

+ **基本カルーセル**
  + Slide、Next Slide Control、Previous Slide Controlの要素を持っているカルーセル
+ **基本カルーセル + Slide Picker Controls**
  + 基本カルーセルと、タブパターンを使用して実装された Slide Picker Controlsがあるカルーセル
+ **グループ カルーセル**
  + 各コントロールがボタンパターンで実装され、基本カルーセル と Slide Picker Controlsが連動するカルーセル

#### ⚪︎ 基本カルーセル
+ カルーセル コンテナ要素には、`role="region"` or `role="group"` のどちらかを指定する
+ カルーセル コンテナ要素には、`aria-roledescription="carousel"` を指定する
+ カルーセルが可視ラベルを持つ場合、可視ラベルのIDをカルーセル コンテナ要素の`aria-labelledby`に指定する
+ カルーセルに可視ラベルがない場合、アクセス可能なラベルは、カルーセル コンテナ要素に`aria-label`で指定する
  + カルーセル コンテナ要素に`aria-roledescription="carousel"`を指定しているので、`aria-label`で`carousel`を使わない
+ Rotation Control、Next Slide Control、Previous Slide Controlは、Button要素かButtonパターンで実装する
+ Rotation Controlは、`aria-label`でラベルを指定する
  + `aria-label="Stop slide rotation"` or `aria-label="Start slide rotation"`
  + ボタンがアクティブになった時に変化するラベルは、スライドの内容が自動的に変化することと、それがいつ行われるかを伝える
  + ラベルが変わるので、`aria-pressed` のような状態は持たない
+ スライド コンテナ要素には、`aria-roledescription="slide"`を設定する
+ 各スライドには、アクセス可能な名前をつける
  + 可視ラベルを持つ場合、可視ラベルのIDをスライド コンテナ要素`aria-labelledby`に設定する
    + そうでなければ、`aria-label`でラベルを設定する
  + スライドの内容を特定する一意の名前が利用できない場合、`3 of 10` などを提供する
  + `aria-roledescription="slide"` が設定されているので、ラベルに `slide` が含まないことに注意
+ 【任意】スライド コンテナには、`aria-atomic="false"` と `aria-live`を設定する
  + `aria-live="off"`：カルーセルが自動で動く場合
  + `aria-live="polite"`：カルーセルが自動動かない場合

#### ⚪︎ 基本カルーセル + Slide Picker Controls

+ スライド コンテナは `role="group"` ではなく `role="tabpanel"` を持つ
  + そのため、`aria-roledescription`は設定しない
+ Slide Picker Controlsはタブパターンを使用して実装する
  + 各コントロールは、タブ要素であり、タブがアクティブになると、関連するスライドが表示される
  + 各タブのlabelは、`slide 3` のように番号を含めることで、どのスライドを表示するかを示す
  + 各コントロールのコンテナは、tablist要素にする
  + tab, tablist tabpanelは、タブパターンで指定したプロパティで実装する

#### ⚪︎ グループ カルーセル

+ Slide Picker Controlsは、`role="group"` を設定する
+ Slide Picker Controlsを持つ要素は、`aria-label` を設定する
  + `Choose slide to display` のようにコントロールの目的を明確にする
+ 各ピッカーは、Button要素かButtonパターンで実装する
+ 各ピッカーには、表示するスライドと一致するラベルを設定するために、スライド要素に `aria-labelledby`を設定する
+ 現在表示されているピッカーは、`aria-disable="true"` を設定する


## アクセシビリティを意識したカルーセルの完成形

<p class="codepen" data-height="400" data-default-tab="result" data-slug-hash="dyayRBP" data-editable="true" data-user="sp_degu" style="height: 400px; 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/sp_degu/pen/dyayRBP">
  Carousel Accessibility</a> by でぐぅー | Qiita (<a href="https://codepen.io/sp_degu">@sp_degu</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

## アクセシビリティを意識したカルーセルの作り方
### 1. HTMLを実装する
```sample.html
<section id="myCarousel" class="carousel-tablist" aria-roledescription="carousel" aria-label="Highlighted television shows">
  <div class="carousel-inner">
    <div id="myCarousel-items" class="carousel-items playing" aria-live="off">
      <div class="carousel-item active" id="carousel-item-1" role="tabpanel" aria-roledescription="slide" aria-label="1 of 4">
        <a href="#" id="carousel-image-1" class="carousel-image">
          <img src="https://drive.google.com/uc?export=view&id=1mwp4HQzhbwYI2ofa4bQQw0a8CuiYa77k" alt="" width="500px" height="300px">
        </a>        
      </div>
      <div class="carousel-item" id="carousel-item-2" role="tabpanel" aria-roledescription="slide" aria-label="2 of 4">
        <a href="#" id="carousel-image-2" class="carousel-image">
          <img src="https://drive.google.com/uc?export=view&id=1n0Jyf55rh72bB9ElPQLcXhKBBlVvFaFB" alt="" width="500px" height="300px">
        </a>
      </div>
      <div class="carousel-item" id="carousel-item-3" role="tabpanel" aria-roledescription="slide" aria-label="3 of 4">
        <a href="#!" id="carousel-image-3" class="carousel-image">
          <img src="https://drive.google.com/uc?export=view&id=1n1jfBoNBl0L3e2qP5HOvxqQjBiHPyDx8" alt="" width="500px" height="300px">
        </a>
      </div>
      
      <div class="carousel-item" id="carousel-item-4" role="tabpanel" aria-roledescription="slide" aria-label="4 of 4">
        <a href="#" id="carousel-image-4" class="carousel-image">
          <img src="https://drive.google.com/uc?export=view&id=1n8lNUmS1oxKcMRLJjmu_ae6ONjVeFAVJ" alt="" width="500px" height="300px">
        </a>
      </div>     
    </div>
    <div class="controls">
      <div class="tab-wrapper" role="tablist" aria-label="Slides">
        <button id="carousel-tab-1" type="button" role="tab" aria-label="Slide 1" aria-selected="true" aria-controls="carousel-item-1"></button>
        <button id="carousel-tab-2" type="button" role="tab" tabindex="-1" aria-label="Slide 2" aria-selected="false" aria-controls="carousel-item-2"></button>
        <button id="carousel-tab-3" type="button" role="tab" tabindex="-1" aria-label="Slide 3" aria-selected="false" aria-controls="carousel-item-3"></button>
        <button id="carousel-tab-4" type="button" role="tab" tabindex="-1" aria-label="Slide 4" aria-selected="false" aria-controls="carousel-item-4"></button>
      </div>
      <button class="rotation" type="button" aria-label="Stop automatic slide show"></button>
    </div>
  </div>
</section>
```

### ２. CSSを実装する
```sample.css
/*　以下スタイル調整　*/
body {
  background-color: #212529;
  color: #fff;
  display: grid;
  height: calc(100vh - 40px);
  margin: 0;
  padding: 20px 0;
  place-items: center;
  width: 100vw;
}

.carousel-tablist {
  background-color: rgb(128 128 128 / .3);
  border-radius: 24px;
  padding: 16px;
  width: 300px;
  background-blend-mode: luminosity;
  backdrop-filter: blur(15px);
  &::before {
    background: linear-gradient(135deg, rgb(255 255 255 / .4) 0, rgb(255 255 255 / 0) 40%, rgb(255 255 255 / 0) 60%, rgb(255 255 255 / .1) 100%);
    border: 1.4px solid transparent;
    border-radius: 24px;
    content: "";
    inset: 0;
    position: absolute;
    -webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0) border-box;
    -webkit-mask-composite: destination-out;
    mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0) border-box;
    mask-composite: exclude;
    z-index: -1;
  }
}

.carousel-tablist .carousel-inner {
  display: grid;
  gap: 8px;
}

.carousel-tablist .carousel-item {
  display: none;
  &.active {
    display: block;
  }
  & a img {
    display: block;
    border-radius: 8px;
    height: 100%;
    width: 100%;
  }
}

.carousel-tablist .controls {
  display: flex;
  justify-content: center; 
  & > button {
    align-items: center;
    background: none;
    border: none;
    cursor: pointer;
    color: #ffffff;
    display: flex;
    justify-content: center;
    padding: 2px;
  }
}

.carousel-tablist .rotation {
  padding: 2px;
  &.pause::before {
    align-items: center;
    justify-content: center;
    display: flex;
    font-family: "Material Symbols Outlined";
    font-variation-settings:
      'FILL' 0,
      'wght' 400,
      'GRAD' 0,
      'opsz' 24;
    content: "pause";
    width: 24px;
    height: 24px;
  }

  &.play::before {
    align-items: center;
    justify-content: center;
    display: flex;
    font-family: "Material Symbols Outlined";
    font-variation-settings:
      'FILL' 0,
      'wght' 400,
      'GRAD' 0,
      'opsz' 24;
    content: "play_arrow";
    width: 24px;
    height: 24px;
  }
}

.carousel-tablist [role="tablist"] {
  padding: 0 8px;
  display: flex;
  align-items: center;
  gap: 8px;
}

.carousel-tablist [role="tab"] {
  border: none;
  border-radius: 50%;
  padding: 0;
  margin: 0;
  background: linear-gradient(0deg, rgba(0, 0, 0, 0.08) 0%, rgba(0, 0, 0, 0.08) 100%), rgba(214, 214, 214, 0.45);
  background-blend-mode: luminosity, color-burn;
  width: 12px;
  height: 12px;
  &:focus, &:hover {
    background: #ffffff;
  }
}

.carousel-tablist [role="tab"][aria-selected="true"] {
  background: #ffffff;
}
```

### 3. JavaScriptを実装する
```sample.js
var CarouselTablist = function (node, options) {
  // merge passed options with defaults
  options = Object.assign(
    { moreaccessible: false, paused: false, norotate: false },
    options || {}
  );

  // a prefers-reduced-motion user setting must always override autoplay
  var hasReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
  if (hasReducedMotion.matches) {
    options.paused = true;
  }

  /* DOM properties */
  this.domNode = node;

  this.tablistNode = node.querySelector('[role=tablist]');
  this.containerNode = node.querySelector('.carousel-items');

  this.tabNodes = [];
  this.tabpanelNodes = [];

  this.liveRegionNode = node.querySelector('.carousel-items');
  this.pausePlayButtonNode = document.querySelector(
    '.carousel-tablist .controls button.rotation'
  );

  this.playLabel = 'Start automatic slide show';
  this.pauseLabel = 'Stop automatic slide show';

  /* State properties */
  this.hasUserActivatedPlay = false; // set when the user activates the play/pause button
  this.isAutoRotationDisabled = options.norotate; // This property for disabling auto rotation
  this.isPlayingEnabled = !options.paused; // This property is also set in updatePlaying method
  this.timeInterval = 5000; // length of slide rotation in ms
  this.currentIndex = 0; // index of current slide
  this.slideTimeout = null; // save reference to setTimeout

  // initialize tabs
  this.tablistNode.addEventListener('focusin', this.handleTabFocus.bind(this));
  this.tablistNode.addEventListener('focusout', this.handleTabBlur.bind(this));

  var nodes = node.querySelectorAll('[role="tab"]');

  for (var i = 0; i < nodes.length; i++) {
    var n = nodes[i];

    this.tabNodes.push(n);

    n.addEventListener('keydown', this.handleTabKeydown.bind(this));
    n.addEventListener('click', this.handleTabClick.bind(this));

    // initialize tabpanels

    var tabpanelNode = document.getElementById(n.getAttribute('aria-controls'));

    if (tabpanelNode) {
      this.tabpanelNodes.push(tabpanelNode);

      // support stopping rotation when any element receives focus in the tabpanel
      tabpanelNode.addEventListener(
        'focusin',
        this.handleTabpanelFocusIn.bind(this)
      );
      tabpanelNode.addEventListener(
        'focusout',
        this.handleTabpanelFocusOut.bind(this)
      );

      var imageLink = tabpanelNode.querySelector('.carousel-image a');

      if (imageLink) {
        imageLink.addEventListener(
          'focus',
          this.handleImageLinkFocus.bind(this)
        );
        imageLink.addEventListener('blur', this.handleImageLinkBlur.bind(this));
      }
    } else {
      this.tabpanelNodes.push(null);
    }
  }

  // Pause Button
  if (this.pausePlayButtonNode) {
    this.pausePlayButtonNode.addEventListener(
      'click',
      this.handlePausePlayButtonClick.bind(this)
    );
  }

  // Handle hover events
  this.domNode.addEventListener('mouseover', this.handleMouseOver.bind(this));
  this.domNode.addEventListener('mouseout', this.handleMouseOut.bind(this));

  // initialize behavior based on options

  this.enableOrDisableAutoRotation(options.norotate);
  this.updatePlaying(!options.paused && !options.norotate);
  this.setAccessibleStyling(options.moreaccessible);
  this.rotateSlides();
};

/* Public function to disable/enable rotation and if false, hide pause/play button*/
CarouselTablist.prototype.enableOrDisableAutoRotation = function (disable) {
  this.isAutoRotationDisabled = disable;
  this.pausePlayButtonNode.hidden = disable;
};

/* Public function to update controls/caption styling */
CarouselTablist.prototype.setAccessibleStyling = function (accessible) {
  if (accessible) {
    this.domNode.classList.add('carousel-tablist-moreaccessible');
  } else {
    this.domNode.classList.remove('carousel-tablist-moreaccessible');
  }
};

CarouselTablist.prototype.hideTabpanel = function (index) {
  var tabNode = this.tabNodes[index];
  var panelNode = this.tabpanelNodes[index];

  tabNode.setAttribute('aria-selected', 'false');
  tabNode.setAttribute('tabindex', '-1');

  if (panelNode) {
    panelNode.classList.remove('active');
  }
};

CarouselTablist.prototype.showTabpanel = function (index, moveFocus) {
  var tabNode = this.tabNodes[index];
  var panelNode = this.tabpanelNodes[index];

  tabNode.setAttribute('aria-selected', 'true');
  tabNode.removeAttribute('tabindex');

  if (panelNode) {
    panelNode.classList.add('active');
  }

  if (moveFocus) {
    tabNode.focus();
  }
};

CarouselTablist.prototype.setSelectedTab = function (index, moveFocus) {
  if (index === this.currentIndex) {
    return;
  }
  this.currentIndex = index;

  for (var i = 0; i < this.tabNodes.length; i++) {
    this.hideTabpanel(i);
  }

  this.showTabpanel(index, moveFocus);
};

CarouselTablist.prototype.setSelectedToPreviousTab = function (moveFocus) {
  var nextIndex = this.currentIndex - 1;

  if (nextIndex < 0) {
    nextIndex = this.tabNodes.length - 1;
  }

  this.setSelectedTab(nextIndex, moveFocus);
};

CarouselTablist.prototype.setSelectedToNextTab = function (moveFocus) {
  var nextIndex = this.currentIndex + 1;

  if (nextIndex >= this.tabNodes.length) {
    nextIndex = 0;
  }

  this.setSelectedTab(nextIndex, moveFocus);
};

CarouselTablist.prototype.rotateSlides = function () {
  if (!this.isAutoRotationDisabled) {
    if (
      (!this.hasFocus && !this.hasHover && this.isPlayingEnabled) ||
      this.hasUserActivatedPlay
    ) {
      this.setSelectedToNextTab(false);
    }
  }

  this.slideTimeout = setTimeout(
    this.rotateSlides.bind(this),
    this.timeInterval
  );
};

CarouselTablist.prototype.updatePlaying = function (play) {
  this.isPlayingEnabled = play;

  if (play) {
    this.pausePlayButtonNode.setAttribute('aria-label', this.pauseLabel);
    this.pausePlayButtonNode.classList.remove('play');
    this.pausePlayButtonNode.classList.add('pause');
    this.liveRegionNode.setAttribute('aria-live', 'off');
  } else {
    this.pausePlayButtonNode.setAttribute('aria-label', this.playLabel);
    this.pausePlayButtonNode.classList.remove('pause');
    this.pausePlayButtonNode.classList.add('play');
    this.liveRegionNode.setAttribute('aria-live', 'polite');
  }
};

/* Event Handlers */

CarouselTablist.prototype.handleImageLinkFocus = function () {
  this.liveRegionNode.classList.add('focus');
};

CarouselTablist.prototype.handleImageLinkBlur = function () {
  this.liveRegionNode.classList.remove('focus');
};

CarouselTablist.prototype.handleMouseOver = function (event) {
  if (!this.pausePlayButtonNode.contains(event.target)) {
    this.hasHover = true;
  }
};

CarouselTablist.prototype.handleMouseOut = function () {
  this.hasHover = false;
};

/* EVENT HANDLERS */

CarouselTablist.prototype.handlePausePlayButtonClick = function () {
  this.hasUserActivatedPlay = !this.isPlayingEnabled;
  this.updatePlaying(!this.isPlayingEnabled);
};

/* Event Handlers for Tabs*/

CarouselTablist.prototype.handleTabKeydown = function (event) {
  var flag = false;

  switch (event.key) {
    case 'ArrowRight':
      this.setSelectedToNextTab(true);
      flag = true;
      break;

    case 'ArrowLeft':
      this.setSelectedToPreviousTab(true);
      flag = true;
      break;

    case 'Home':
      this.setSelectedTab(0, true);
      flag = true;
      break;

    case 'End':
      this.setSelectedTab(this.tabNodes.length - 1, true);
      flag = true;
      break;

    default:
      break;
  }

  if (flag) {
    event.stopPropagation();
    event.preventDefault();
  }
};

CarouselTablist.prototype.handleTabClick = function (event) {
  var index = this.tabNodes.indexOf(event.currentTarget);
  this.setSelectedTab(index, true);
};

CarouselTablist.prototype.handleTabFocus = function () {
  this.tablistNode.classList.add('focus');
  this.liveRegionNode.setAttribute('aria-live', 'polite');
  this.hasFocus = true;
};

CarouselTablist.prototype.handleTabBlur = function () {
  this.tablistNode.classList.remove('focus');
  if (this.playState) {
    this.liveRegionNode.setAttribute('aria-live', 'off');
  }

  this.hasFocus = false;
};

/* Event Handlers for Tabpanels*/

CarouselTablist.prototype.handleTabpanelFocusIn = function () {
  this.hasFocus = true;
};

CarouselTablist.prototype.handleTabpanelFocusOut = function () {
  this.hasFocus = false;
};

/* Initialize Carousel Tablists and options */

window.addEventListener(
  'load',
  function () {
    var carouselEls = document.querySelectorAll('.carousel-tablist');
    var carousels = [];

    // set example behavior based on
    // default setting of the checkboxes and the parameters in the URL
    // update checkboxes based on any corresponding URL parameters
    var checkboxes = document.querySelectorAll(
      '.carousel-options input[type=checkbox]'
    );
    var urlParams = new URLSearchParams(location.search);
    var carouselOptions = {};

    // initialize example features based on
    // default setting of the checkboxes and the parameters in the URL
    // update checkboxes based on any corresponding URL parameters
    checkboxes.forEach(function (checkbox) {
      var checked = checkbox.checked;

      if (urlParams.has(checkbox.value)) {
        var urlParam = urlParams.get(checkbox.value);
        if (typeof urlParam === 'string') {
          checked = urlParam === 'true';
          checkbox.checked = checked;
        }
      }

      carouselOptions[checkbox.value] = checkbox.checked;
    });

    carouselEls.forEach(function (node) {
      carousels.push(new CarouselTablist(node, carouselOptions));
    });

    // add change event to checkboxes
    checkboxes.forEach(function (checkbox) {
      var updateEvent;
      switch (checkbox.value) {
        case 'moreaccessible':
          updateEvent = 'setAccessibleStyling';
          break;
        case 'norotate':
          updateEvent = 'enableOrDisableAutoRotation';
          break;
      }

      // update the carousel behavior and URL when a checkbox state changes
      checkbox.addEventListener('change', function (event) {
        urlParams.set(event.target.value, event.target.checked + '');
        window.history.replaceState(
          null,
          '',
          window.location.pathname + '?' + urlParams
        );

        if (updateEvent) {
          carousels.forEach(function (carousel) {
            carousel[updateEvent](event.target.checked);
          });
        }
      });
    });
  },
  false
);
```

## まとめ
この記事では、「**カルーセル**」に焦点を当てて、アクセシビリティを意識したカルーセルの実装方法とカルーセルで意識した方がいいアクセシビリティを解説しました。

ぜひこの記事をストックして、カルーセルを実装する時にアクセシビリティについて思い出してもらえると嬉しいです。

Advent Calendar 2023では、他のコンポーネントにも焦点を当てて、アクセシビリティについても解説しているので、ぜひ購読していてください。

https://qiita.com/advent-calendar/2023/degudegu2510

---

最後まで読んでくださってありがとうございます！

普段はデザインやフロントエンドを中心にQiitaに記事を投稿しているので、ぜひQiitaのフォローとX(Twitter)のフォローをお願いします。

https://twitter.com/degudegu2510