【アクセシビリティ】アクセシビリティを意識したチェックボックスの作り方

## はじめに
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/390720/811f0c4b-8808-58da-342d-fdde88d8c99e.png)

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

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

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

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

## アクセシビリティを意識したチェックボックスの仕様
### ⚪︎ チェックボックスとは？
チェックボックスは、`チェック済み` と `チェック済みでない` 状態を切り替えることができる要素です。
また、一部のチェックボックスでは、`チェック済み` と `チェック済みでない` 、`部分的にチェック済み` 状態を切り替えることができます。

3つ以上の状態を切り替えることができるチェックボックスは、1つのチェックボックスがチェックボックスグループ全体を `チェック済み` と `チェック済みでない` の状態を切り替え、チェックボックスグループの各要素は、個別に `チェック済み` と `チェック済みでない` の状態を切り替えるものです。

3つ以上の状態を切り替えることができるチェックボックスは以下のような機能がが必要です。

+ チェックボックスグループの各要素が全部が `チェック済み` の場合、チェックボックスグループ全体のチェックボックスも `チェック済み` になる
+ チェックボックスグループの各要素の一部が `チェック済み` の場合、チェックボックスグループ全体のチェックボックスは、`部分的にチェック済み` になる
+ チェックボックスグループの各要素の全部が `チェック済みでない` の場合、チェックボックスグループ全体のチェックボックスは、`チェック済みでない` になる
+ チェックボックスグループ全体のチェックボックスを `チェック済み` にすると、チェックボックスグループの各要素が全部が `チェック済み` になる
+ チェックボックスグループ全体のチェックボックスを `チェック済みでない` にすると、チェックボックスグループの各要素が全部が `チェック済みでない` になる

### ⚪︎ キーボードインタラクション
チェックボックスにフォーカスがある時、`Spaceキー` を押すとチェックボックスの状態が変わる。

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

+ チェックボックスには、`role="checkbox"` が設定されている
+ チェックボックスは以下のどれかで、アクセス可能なラベルを設定する
  + 要素内に含まれる可視テキストコンテンツ
  + チェックボックスの要素に設定された、`aria-labelledby` によって参照される可視ラベル
  + チェックボックスの要素に設定された、`aria-label`
+ チェック済みの時、`aria-checked="true"`　を設定する
+ 部分的にチェック済みの場合は、`aria-checked="mixed"`　を設定する

## アクセシビリティを意識したチェックボックスの完成形
<p class="codepen" data-height="400" data-default-tab="result" data-slug-hash="KKJKyxy" 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/KKJKyxy">
  Button 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
<div class="checkbox-container toggle">
  <label><input type="checkbox" aria-checked="false"/>りんご 🍎</label>
  <label><input type="checkbox" aria-checked="false"/>なし 🍐</label>
  <label><input type="checkbox" aria-checked="false"/>バナナ 🍌</label>
  <label><input type="checkbox" aria-checked="false"/>スイカ 🍉</label>
</div>

<div class="checkbox-container tri">
  <label id="all"><input type="checkbox" aria-checked="false" aria-controls="cond1 cond2 cond3 cond4"/>全部チェックする</label>
  <div id="checkbox-group">
    <label><input type="checkbox" id="cond1" aria-checked="false"/>りんご 🍎</label>
    <label><input type="checkbox" id="cond2" aria-checked="false"/>バナナ 🍌</label>
    <label><input type="checkbox" id="cond3" aria-checked="false" />スイカ 🍉</label>  
  </div>
</div>
```

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

.checkbox-container {
  background-color: rgb(128 128 128 / .3);
  border-radius: 24px;
  display: grid;
  gap: 8px;
  padding: 16px;
  position: relative;
  min-width: 150px;
  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;
  }
}

label {
  cursor: pointer;
  padding: 4px;
  
  & > input[type="checkbox"] {
    cursor: pointer;
    margin-right: 8px;
    position: relative;
  }
  & > input[aria-checked="mixed"]::after {
    background-color: #212529;
    border-radius: 2px;
    content: "";
    display: block;
    height: 3px;
    position: absolute;
    width: 7px;
    top: 5px;
    left: 3px;
  }
}

#checkbox-group {
  padding-left: 24px;
  display: grid;
  gap: 8px;
}
```

### 3. JavaScriptを実装する

```sample.js
 window.addEventListener('load', function () {
  let toggleCheckboxes = document.querySelectorAll('.toggle label input[type="checkbox"]');
  for (let i = 0; i < toggleCheckboxes.length; i++) {
    new ToggleCheckbox(toggleCheckboxes[i]);
  }
});

class ToggleCheckbox {
  constructor(domNode) {
    this.domNode = domNode;
    this.domNode.addEventListener('keydown', this.onKeydown.bind(this));
    this.domNode.addEventListener('click', this.onClick.bind(this));
  }
  
  toggleCheckbox() {
    if (this.domNode.getAttribute('aria-checked') === 'true') {
      this.domNode.setAttribute('aria-checked', 'false');
    } else {
      this.domNode.setAttribute('aria-checked', 'true');
    }
  }
  
  onKeydown(event) {
    if (event.key === ' ') {
      this.toggleCheckbox()
    }
  }
  
  onClick(event) {
    this.toggleCheckbox()
  }
  
}


class CheckboxMixed {
  constructor(domNode) {
    this.mixedNode = domNode.querySelector('.tri #all input[type="checkbox"]');
    this.checkboxNodes = domNode.querySelectorAll('.tri #checkbox-group input[type="checkbox"]');

    this.mixedNode.addEventListener('keydown', this.onMixedKeydown.bind(this));
    this.mixedNode.addEventListener('click', this.onMixedClick.bind(this));

    for (var i = 0; i < this.checkboxNodes.length; i++) {
      var checkboxNode = this.checkboxNodes[i];

      checkboxNode.addEventListener('click', this.onCheckboxClick.bind(this));
      checkboxNode.setAttribute('data-last-state', checkboxNode.checked);
    }

    this.updateMixed();
  }

  updateMixed() {
    var count = 0;

    for (var i = 0; i < this.checkboxNodes.length; i++) {
      if (this.checkboxNodes[i].checked) {
        count++;
      }
    }

    if (count === 0) {
      this.mixedNode.setAttribute('aria-checked', 'false');
      this.mixedNode.checked = false;
    } else {
      if (count === this.checkboxNodes.length) {
        this.mixedNode.setAttribute('aria-checked', 'true');
      this.mixedNode.checked = true;
      } else {
        this.mixedNode.setAttribute('aria-checked', 'mixed');
        this.updateCheckboxStates();
        this.mixedNode.checked = false;
      }
    }
  }

  updateCheckboxStates() {
    for (var i = 0; i < this.checkboxNodes.length; i++) {
      var checkboxNode = this.checkboxNodes[i];
      checkboxNode.setAttribute('data-last-state', checkboxNode.checked);
    }
  }

  anyLastChecked() {
    var count = 0;

    for (var i = 0; i < this.checkboxNodes.length; i++) {
      if (this.checkboxNodes[i].getAttribute('data-last-state') == 'true') {
        count++;
      }
    }

    return count > 0;
  }

  setCheckboxes(value) {
    for (var i = 0; i < this.checkboxNodes.length; i++) {
      var checkboxNode = this.checkboxNodes[i];

      switch (value) {
        case 'last':
          checkboxNode.checked =
            checkboxNode.getAttribute('data-last-state') === 'true';
          break;

        case 'true':
          checkboxNode.checked = true;
          break;

        default:
          checkboxNode.checked = false;
          break;
      }
    }
    this.updateMixed();
  }

  toggleMixed() {
    var state = this.mixedNode.getAttribute('aria-checked');

    if (state === 'false') {
      if (this.anyLastChecked()) {
        this.setCheckboxes('last');
      } else {
        this.setCheckboxes('true');
      }
    } else {
      if (state === 'mixed') {
        this.setCheckboxes('true');
      } else {
        this.setCheckboxes('false');
      }
    }

    this.updateMixed();
  }

  /* EVENT HANDLERS */

  // Prevent page scrolling on space down
  onMixedKeydown(event) {
    if (event.key === ' ') {
      event.preventDefault();
    }
  }

  onMixedClick() {
    this.toggleMixed();
  }

  onCheckboxClick(event) {
    event.currentTarget.setAttribute(
      'data-last-state',
      event.currentTarget.checked
    );
    this.updateMixed();
  }
}

// Initialize mixed checkboxes on the page
window.addEventListener('load', function () {
  let mixed = document.querySelectorAll('.tri');
  for (let i = 0; i < mixed.length; i++) {
    new CheckboxMixed(mixed[i]);
  }
});
```

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

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

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

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

---

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

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

https://twitter.com/degudegu2510