【アクセシビリティ】アクセシビリティを意識したラジオボタンの作り方

## はじめに
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/390720/2d5d215f-1745-a880-6a25-3c3152b7065b.png)

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

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

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

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

## アクセシビリティを意識したラジオボタンの仕様
### ⚪︎ ラジオボタンとは？
ラジオグループは、ラジオボタンとして知られるチェック可能なボタンの集まりで、一度に1つ以上のボタンをチェックすることはできません。
一部の実装では、ユーザーがボタンのいずれかをチェックするよう強制するために、すべてのボタンを未チェック状態で初期化することがあります。

### ⚪︎ キーボードインタラクション
#### ツールバー以外のラジオボタン

+ **`Tabキー`・`Shift + Tabキー`**
  + ラジオグループへのフォーカスの移動とグループ外へのフォーカスの移動
    + ラジオボタンがチェックされている場合、フォーカスはチェックされたボタンに移動する
    + ラジオボタンが一つもチェックされていない場合、フォーカスはグループ内の最初のラジオボタンに移動する
+ **`Spaceキー`**
  + フォーカスされているラジオボタンがまだチェックされていない場合、そのボタンをチェックする
+ **`→キー`・`↓キー`**
  + グループ内の次のラジオボタンにフォーカスを移動する
  + 以前にフォーカスされたボタンのチェックを外し、新しくフォーカスされたボタンをチェックする
  + 最後のボタンにフォーカスがある場合、フォーカスは最初のボタンに移動する
+ **`←キー`・`↑キー`**
  + グループ内の前のラジオボタンにフォーカスを移動する
  + 以前にフォーカスされたボタンのチェックを外し、新しくフォーカスされたボタンをチェックする。
  + 最初のボタンにフォーカスがある場合、フォーカスは最後のボタンに移動する

#### ツールバーのラジオボタン
ツールバーでは、矢印キーは要素間をナビゲーションするため、ラジオボタンの矢印キーの挙動が少し変わります。

+ **`Spaceキー`**
  + フォーカスされているラジオボタンがチェックされていない場合、現在チェックされているラジオボタンのチェックを外し、フォーカスされているラジオボタンをチェックする
    + それ以外の場合は何もしない
+ **`Enterキー`**（任意）
  + フォーカスされているラジオボタンがチェックされていない場合、現在チェックされているラジオボタンのチェックを外し、フォーカスされているラジオボタンをチェックする
    + それ以外の場合は何もしない
+ **`→キー`**
  + フォーカスがラジオボタンにあり、そのラジオボタンがラジオグループ内の最後のラジオボタンでない場合、次のラジオボタンにフォーカスを移動する
  + フォーカスがラジオグループ内の最後のラジオボタンにあり、そのラジオボタンがツールバーの最後の要素でない場合、ツールバー内の次の要素にフォーカスを移動する
  + フォーカスがラジオグループ内の最後のラジオボタンにあり、そのラジオボタンがツールバーの最後の要素でもある場合、ツールバー内の最初の要素にフォーカスを移動する
+ **`←キー`**
  + フォーカスがラジオボタンにあり、そのラジオボタンがラジオグループ内の最初のラジオボタンでない場合、前のラジオボタンにフォーカスを移動する
  + フォーカスがラジオグループ内の最初のラジオボタンにあり、そのラジオボタンがツールバーの最初の要素でない場合、ツールバー内の前の要素にフォーカスを移動する
  + フォーカスがラジオグループ内の最初のラジオボタンにあり、そのラジオボタンがツールバーの最初の要素でもある場合、ツールバー内の最後の要素にフォーカスを移動する
+ **`↓キー`**（任意）
  + ラジオグループ内の次のラジオボタンにフォーカスを移動する
  + フォーカスがラジオグループ内の最後のラジオボタンにある場合、グループ内の最初のラジオボタンにフォーカスを移動する
+ **`↑キー`**（任意）
  + ラジオグループ内の前のラジオボタンにフォーカスを移動する
  + フォーカスがラジオグループ内の最初のラジオボタンにある場合、グループ内の最後のラジオボタンにフォーカスを移動する

### ⚪︎ WAI-ARIA の役割、状態、プロパティ

+ ラジオボタンは、`role="radiogroup"` を持つ要素に含まれているか、その要素によって所有されている
+ 各ラジオボタンには `role="radio"` を設定する
+ ラジオボタンがチェックされている場合、そのradio要素には `aria-checked="true"` を設定する
+ ラジオボタンがチェックされていない場合は、そのradio要素には `aria-checked="false"` を設定する
+ 各radio要素は、以下の要素のいずれかでラベル付けされる
  + そのコンテンツによってラベル付けされている
  + `aria-labelledby` で参照される可視ラベルを持っている
  + `aria-label` で指定される
+ `role="radiogroup"` を持つ要素には、以下の要素のいずれかでラベル付けされる
  + `aria-labelledby` で参照される可視ラベルを持っている
  + `aria-label` でラベルが指定される
+ ラジオグループまたは各ラジオボタンに関する追加情報を提供する要素が存在する場合、それらの要素はradiogroup要素またはradio要素に `aria-describedby` によって参照される


## アクセシビリティを意識したラジオボタンの完成形
<p class="codepen" data-height="500" data-default-tab="result" data-slug-hash="oNmyGNL" data-editable="true" data-user="sp_degu" style="height: 500px; 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/oNmyGNL">
  Radio group Accessibillity</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
<fieldset role="radiogroup" class="container">
  <label>
    <input type="radio" name="fruits" checked/>
    りんご
  </label>
  <label>
    <input type="radio" name="fruits"/>
    なし
  </label>
  <label>
    <input type="radio" name="fruits"/>
    バナナ
  </label>
  <label>
    <input type="radio" name="fruits"/>
    すいか
  </label>
</fieldset>
```

### ２. 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;
}

.container {
  backdrop-filter: blur(50px);
  background-color: rgb(128 128 128 / .3);
  background-blend-mode: luminosity;
  border: none;
  border-radius: 32px;
  display: grid;
  padding: 20px;
  position: relative;
  &::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: 32px;
    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;
  }
}

label {
  cursor: pointer;
  font-size: 20px;
  padding: 8px 12px;
  width: 200px;
  &:hover {
    border-radius: 8px;
    background: radial-gradient(34.12% 136.61% at 50% 100%, rgba(94, 94, 94, 0.14) 0%, rgba(94, 94, 94, 0.00) 73.85%), radial-gradient(50% 164.29% at 50% 100%, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0.00) 60.33%), linear-gradient(0deg, rgba(94, 94, 94, 0.18) 0%, rgba(94, 94, 94, 0.18) 100%), rgba(255, 255, 255, 0.06);
    background-blend-mode: color-dodge, normal, color-dodge, lighten;
  }
}
```

### 3. JavaScriptを実装する
```sample.js
class RadioGroup {
  constructor(groupNode) {
    this.groupNode = groupNode;

    this.radioButtons = [];

    this.firstRadioButton = null;
    this.lastRadioButton = null;

    var rbs = this.groupNode.querySelectorAll('[type=radio]');

    for (var i = 0; i < rbs.length; i++) {
      var rb = rbs[i];

      rb.tabIndex = -1;
      rb.setAttribute('aria-checked', 'false');

      rb.addEventListener('keydown', this.handleKeydown.bind(this));
      rb.addEventListener('click', this.handleClick.bind(this));
      rb.addEventListener('focus', this.handleFocus.bind(this));
      rb.addEventListener('blur', this.handleBlur.bind(this));

      this.radioButtons.push(rb);

      if (!this.firstRadioButton) {
        this.firstRadioButton = rb;
      }
      this.lastRadioButton = rb;
    }
    this.firstRadioButton.tabIndex = 0;
  }

  setChecked(currentItem) {
    for (var i = 0; i < this.radioButtons.length; i++) {
      var rb = this.radioButtons[i];
      rb.setAttribute('aria-checked', 'false');
      rb.tabIndex = -1;
    }
    currentItem.checked = true;
    currentItem.setAttribute('aria-checked', 'true');
    currentItem.tabIndex = 0;
    currentItem.focus();
  }

  setCheckedToPreviousItem(currentItem) {
    var index;

    if (currentItem === this.firstRadioButton) {
      this.setChecked(this.lastRadioButton);
    } else {
      index = this.radioButtons.indexOf(currentItem);
      this.setChecked(this.radioButtons[index - 1]);
    }
  }

  setCheckedToNextItem(currentItem) {
    var index;

    if (currentItem === this.lastRadioButton) {
      this.setChecked(this.firstRadioButton);
    } else {
      index = this.radioButtons.indexOf(currentItem);
      this.setChecked(this.radioButtons[index + 1]);
    }
  }

  handleKeydown(event) {
    var tgt = event.currentTarget,
      flag = false;

    switch (event.key) {
      case ' ':
      case 'Enter':
        this.setChecked(tgt);
        flag = true;
        break;

      case 'Up':
      case 'ArrowUp':
      case 'Left':
      case 'ArrowLeft':
        this.setCheckedToPreviousItem(tgt);
        flag = true;
        break;

      case 'Down':
      case 'ArrowDown':
      case 'Right':
      case 'ArrowRight':
        this.setCheckedToNextItem(tgt);
        flag = true;
        break;

      default:
        break;
    }

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

  handleClick(event) {
    this.setChecked(event.currentTarget);
  }

  handleFocus(event) {
    event.currentTarget.classList.add('focus');
  }

  handleBlur(event) {
    event.currentTarget.classList.remove('focus');
  }
}

window.addEventListener('load', function () {
  var radios = document.querySelectorAll('[role=radiogroup]');
  for (var i = 0; i < radios.length; i++) {
    new RadioGroup(radios[i]);
  }
});
```

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

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

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

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

---

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

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

https://twitter.com/degudegu2510