アプリのアクセシビリティ改善で行っていること

ゼロバンク・デザインファクトリー株式会社(ZDF)でフロントエンドエンジニアをしている長島です。先日 note に[「アプリのアクセシビリティを改善しています」](https://note.minna-no-ginko.com/n/n84637dd5b03a)という記事を書きました。この記事では、実際のスクリーンリーダーによる動作確認の画面録画を参照しつつ、具体的にどのような改修を進めているのかについて書きます。

- [改善前のスクリーンリーダーの読み上げ](https://www.youtube.com/watch?v=0z3eL2r9eq8)
- [改善後のスクリーンリーダーの読み上げ](https://www.youtube.com/watch?v=FsDcevSP54Q)

特定のフレームワークのバージョンに依存する内容も一部ありますが、極力 HTML・CSS・JavaScript を使っている人に広く参考になるよう適宜補足を入れています。コードサンプルはプロジェクト固有の事情をぼかすために、敢えて大きく単純化しているのでみんなの銀行アプリの実際の挙動とは若干異なります。あくまで記事の趣旨を理解するための補助にとどめてください。

次に、この記事に書いてある修正はほとんどが善後策です。新規にプロジェクトが始まる場合、このようなハックをせずセマンティックな HTML マークアップをする方が簡単ですし、よりアクセシブルになります。既存プロジェクトの改善をする、かつマークアップを大きく変えるのが難しい場合にはこの記事が参考になるかもしれません。

## みんなの銀行アプリの構成

みんなの銀行は Ionic・Angular・Cordova を採用しています。

### Angular

[Angular]((https://angular.jp/guide/what-is-angular)) は Google が開発しているオープンソースの web フレームワークです。ルーティング、フォーム、HTTP 通信など web アプリの開発に必要なライブラリが一通り含まれています。Google I/O 2023 のセッションでは"Angular is Google's framework that is the default recommendation for web apps"と推奨の Web フレームワークである旨の発言があり、デベロッパー基調講演でも複数回 Angular について触れられていました。レガシーな印象があるかもしれませんが、今後も Google による活発な開発が続けられていくだろうと思われます。

### Ionic

[Ionic]((https://ionicframework.com/docs/))はクロスプラットフォームのモバイルアプリを 1 つのコードベースから作るためのフレームワークです。豊富な UI コンポーネントがあり、それらを組み合わせるだけでネイティブの見た目に近い UI を再現することができます。Angular だけでなく React や Vue と組み合わせることが可能です。

### Cordova

Angular と Ionic を [Cordova]((https://cordova.apache.org/)) と組み合わせると、 web 技術でスマホアプリを作ることができます。みんなの銀行アプリでは Cordova 経由で Push 通知や生体認証などの機能を使っています。

上記の内容を図にまとめると以下のようになります。

![アプリ構成図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583874/5fcfc374-b09f-d912-4fec-d92948f6e9f3.png)


## 改修内容

上述の通りみんなの銀行は web 技術で作られているので、HTML や TypeScript を修正することでアクセシビリティの改善を行なっています。

### 言語設定を日本語にする

改善前の挙動でまず問題なのが、日本語のアプリであるにも関わらず[英語で読み上げられている](https://youtu.be/0z3eL2r9eq8?si=P3Q5Lu-0RvtOgrPs&t=2)点です。

`ionic start`や`ng new`で生成された`index.html`は lang 属性が"en"(英語)になっています。スクリーンリーダーが英語で読み上げてしまうので、"ja"(日本語)にする必要があります。

```html
<html lang="ja"></html>
```

### 視覚的に表示していない内容をスクリーンリーダーから隠す（表示している内容を過不足なくスクリーンリーダーに伝える）

スクリーンリーダーが把握しているコンテンツが視覚的な表示からずれていると、[意図した箇所に意図した順番でフォーカスが当たらなかったり、読み上げと表示内容が不整合になったりします。](https://youtu.be/0z3eL2r9eq8?si=U5Sn9ZnoMIFt7whR&t=18)前者については正しい順番で操作できなくなるので、問題であることが分かりやすいかと思います。後者についても、弱視の人や識字障害がある人が目で見ながらスクリーンリーダーを使うことがあるので、読み上げと表示内容が矛盾すると混乱の原因になり得ます。

みんなの銀行アプリでは、カルーセルとモーダルを使っている箇所でこのような問題が起きています。

#### 表示されていないカルーセルのスライドを隠す

まず、横幅一杯に表示したカルーセルをスライドさせて表示内容を切り替えている箇所があります。例えば[改善後の画面収録ではロゴ画面からログイン画面にスライド](https://www.youtube.com/watch?v=FsDcevSP54Q&t=5s)しています。

表示していないスライドは支援技術から隠す必要があります。いくつか方法はありますが、みんなの銀行では[inert 属性](https://developer.mozilla.org/ja/docs/Web/HTML/Global_attributes/inert)を使っています。

1. カルーセルを読み込んだとき
2. 表示するスライドを切り替えたとき

の 2 つのタイミングで、表示しているスライドからは inert 属性を外し、表示していないスライドには inert 属性をつけます。みんなの銀行は Ionic5 系と Angular を使っているので、ion-slides と Directive/Service で実装していますが、基本的な考え方は他のライブラリやフレームワークでも同様かと思います。

```html:compoennt.html
<ion-slides appSlides></ion-slides>
```

```typescript:slides.directive.ts
class SlideDirective {
  constructor(
    private inertService: InertService,
    private elementRef: ElementRef<HTMLIonSlidesElement>
  ) {}
  
  @HostListener('ionSlidesDidLoad')
  public onLoad(): void {
    const { inactiveSlides } = this.classifyIonSlideArray();
    inactiveSlides.forEach(inactiveSlide => {
      this.inertService.setInert(inactiveSlide);
    });
  }

  @HostListener('ionSlideDidChange')
  public onChange(): void {
    const { activeSlide, inactiveSlides } = this.classifyIonSlideArray();
    this.inertService.removeInert(activeSlide);

    // 見出しにフォーカスする。詳細後述
    const headingLevels = [1,2,3,4,5,6];
    const headingSelector = headingLevels.map((headingLevel) => `h${headingLevel}[tabindex]`).join(",");
    const heading = document.querySelector(headingSelector)
    if(heading){
      heading.focus();
    }


    inactiveSlides.forEach(inactiveSlide => {
      this.inertService.setInert(inactiveSlide);
    });
  }

  private classifyIonSlideArray(): {
    activeSlide: HTMLIonSlideElement;
    inactiveSlides: HTMLIonSlideElement[];
  } {
    const nativeElement: HTMLElement = this.elementRef.nativeElement;
    const activeSlide: HTMLIonSlideElement = nativeElement.querySelector(
      ':scope > div > ion-slide.swiper-slide-active'
    );
    const inactiveSlides: HTMLIonSlideElement[] = Array.from(
      nativeElement.querySelectorAll(
        ':scope > div > ion-slide:not(.swiper-slide-active)'
      )
    );

    return {
      activeSlide,
      inactiveSlides
    };
  }
}
```

```typescript:inert.service.ts
class InertService {
  public removeInert(element: HTMLElement): void {
    // 不要なinertのつけ外しはスキップ。詳細後述
    if (!element.inert) {
      return;
    }
    element.removeAttribute('inert');
  }

  public setInert(element: HTMLElement): void {
    // 不要なinertのつけ外しはスキップ。詳細後述
    if (element.inert) {
      return;
    }
    element.setAttribute('inert', '');
  }
}
```

#### モーダルの後ろにある内容を隠す

また、モーダルを使っている箇所では横軸方向ではなく奥行き方向(CSS でいう z-index の方向)で同様の問題が起きています。みんなの銀行アプリはトップ画面に覆い被さるような形でログイン画面を表示しており、改善前の録画では[ログイン画面を表示しているにも関わらずトップ画面を読み上げてしまいます。](https://youtu.be/0z3eL2r9eq8?si=U5Sn9ZnoMIFt7whR&t=18)

基本的には

1. モーダルを立ち上げるタイミングで裏側の要素に inert 属性を付与
2. モーダルを閉じるタイミングで裏側の要素から inert 属性を削除

で改善できます。

```html:slides.html
<!-- モーダルとして立ち上げるコンポーネントのテンプレートにDirectiveを適用 -->
<ion-header appModal><ion-header>
```

```typescript:modal.directive.ts
class ModalDirective implements AfterViewInit, OnDestroy {
  private rootWasAlreadyInert: boolean;
  private inactivatedModals: Array<HTMLIonModalElement>;
  private root: HTMLIonRouterOutletElement;

  constructor(
    private elementRef: ElementRef<HTMLElement>,
    private inertService: InertService,
    private modalController: ModalController
  ) {}

  public ngAfterViewInit(): void {
    // モーダルを閉じた直後に別のモーダルを表示した場合、閉じた方のモーダルのngOnDestroyより先に立ち上げたモーダルのngAfterViewInitが発火する場合がある。詳細後述
    setTimeout(() => {
      this.setInert();
    }, 800);
  }

  private async setInert(): Promise<void> {
    // ion-router-outletが既にinertだった場合、ngOnDestroyでinertを外してはいけない。詳細後述
    this.root = document.querySelector('ion-router-outlet');
    this.rootWasAlreadyInert = this.root.hasAttribute('inert');
    if (!this.rootWasAlreadyInert) {
      this.inertService.setInert(this.root);
    }

    // 立て続けにモーダルを表示した場合、parentModalとtopModalが異なる場合がある
    const parentModal = this.elementRef.nativeElement.closest('ion-modal');
    const topModal = await this.modalController.getTop();
    this.inactivatedModals = Array.from(
      document.querySelectorAll(
        `ion-modal:not([inert]):not(#${parentModal.id}:not(#${topModal.id}))`
      )
    );
    this.inactivatedModals.forEach(inactivatedModal => {
      this.inertService.setInert(inactivatedModal);
    });    

    this.inertService.setInert(this.root);
  }

  public ngOnDestroy(): void {
    if (!this.rootWasAlreadyInert) {
      this.inertService.removeInert(this.root);
    }

    this.inactivatedModals.forEach(inactivatedModal => {
      this.inertService.removeInert(inactivatedModal);
    });
  }
}
```

##### 実装上の注意点

inert のつけ外しを実装する場合、2 点注意点があります。

###### 1.複数のモーダルを立ち上げた場合

複数のモーダルを立ち上げた後にそのうちのいくつかのモーダルを閉じる場合、立ち上げた時点で inert を付与した要素以外からは inert 属性を削除してはいけません。

例えば、

1. 画面 A
2. モーダル B
3. モーダル C

の順に遷移したとします。モーダル B を立ち上げたときには画面 A に inert をつけ、モーダル C のときにはモーダル B に inert 属性をつけます。そしてモーダル C を閉じる際にはモーダル B の inert 属性を削除しますが、画面 A には inert をつけたままにする必要があります。モーダル B がまだ画面 A の上にあるからです。

###### 2.モーダルを閉じた直後に別のモーダルを立ち上げた場合

例えば、

1. 画面 A へ遷移
2. モーダル B を立ち上げる
3. モーダル B を閉じる
4. 3 の直後にモーダル C を立ち上げる

という遷移があったとします。その際、フレームワークのライフサイクルの発火タイミング次第ではモーダル B の inert 付与処理が画面 A の inert 削除処理よりも先に実行され、

1. モーダル B が画面 A に inert を付与
2. モーダル C が画面 A に inert を付与する必要があるかチェック。既に inert がついているので付与しない
3. モーダル B が画面 A の inert を削除

という順番になります。結果、モーダル C が画面 A の上にあるにも関わらず、画面 A がスクリーンリーダーから操作できる状態になってしまいます。適当な時間分、モーダル立ち上げ時の処理を遅らせることでこの事象を回避しています。

##### (可能な場合)最新の Ionic や dialog 要素を利用する

上記の通り、モーダルを正しく隠すためには若干複雑な実装が必要です。最新の Ionic や dialog 要素を利用すれば、適切に隠すべき箇所を隠してくれるので上記のような独自実装が不要です。可能であればそちらを使う方が良いかと思います。

Ionic7 系でも、v7.4.0 以前には上記の「複数のモーダルを立ち上げた場合」であげたのとほぼ同じ問題があるのに注意してください(Ionic は inert 属性ではなく aria-hidden 属性を使っています)。[GitHub に Issue をあげ修正を提案](https://github.com/ionic-team/ionic-framework/issues/28180)したところ、v7.4.1 で概ね上記の通りの修正を入れていただけました。

#### inert のポリフィル

inert 属性が使えないブラウザをサポートする場合[ポリフィル](https://github.com/WICG/inert)があります。注意点として、DOM 構造を走査するのでアニメーションやスクロールの途中で inert 属性をつけると、パフォーマンスに問題が出る可能性があります([performance-and-gotchas](https://github.com/WICG/inert#performance-and-gotchas))。requestIdleCallback や setTimeout などを用いて、画面のレンダリングに影響が出ないようにする必要があるかもしれません。

### 画像やアイコンに代替テキストをつけるか、装飾の場合は支援技術から無視できるようにする

また、改善前は[画像やアイコンに代替テキストがない又はユーザーに意味が分からない文字列が代替テキストになっている](https://youtu.be/0z3eL2r9eq8?si=AYaivaTMrsC4CqgK&t=27)状態でした。これだと画像やアイコンの意味がスクリーンリーダーユーザーに伝わりません。

みんなの銀行で使われている画像はほとんどの場合装飾です。alt に空文字を指定することでスクリーンリーダーに装飾用の画像であることが伝わります。

```html
<img alt="" />
```

alt 属性以外にも、aria-label や aria-labelledby などの属性を使って代替テキストを指定することもできます。しかし Ionic の ion-icon と組み合わせて aria-label や aria-labelledby を使った場合、name 属性と aria-label・aria-labelledby 属性両方の値を読み上げてしまうスクリーンリーダーがあります。なのでみんなの銀行ではアイコンは aria-hidden="true"にしてスクリーンリーダーから隠し、button に名前をつけるという実装にしています。

### ボタンなどの要素の役割と名前を指定する

Youtube の動画に分かりやすい例はありませんが、改善前のみんなの銀行アプリではボタンなどの UI の役割が読み上げられず、操作可能な UI であることがスクリーンリーダーの読み上げからは分からない箇所が多くありました。ボタンには`button`を使う、チェックボックスには`input type="checkbox"`を使うなど、セマンティックな HTML マークアップをしていればこのような問題は起きません。

マークアップを変えるのが根本的な改善になりますが、それが難しい場合は role 属性を用いることができます。

```html
<ion-item role="button" aria-label="次へ" tabindex="0">
  <ion-icon aria-hidden="true" name="arrow-forward-outline"></ion-icon>
</div>
```

tabindex="0"をつけることで、キーボードの tab キーで遷移ができるようにしています。スマホアプリでキーボードを使えるようにする必要性は分かりにくいかもしれませんが、bluetooth などでキーボードをスマホに接続して使う人もいますし、キーボードを代替する支援技術からも使えるようになります。

現状 ion-item をボタンとして使っている箇所は上記のようなマークアップにしていますが、後述の理由で
```html
<ion-item [button]="true" [detail]="false"></ion-item>
```
とするべきだったかもしれません。

#### Ionic6 の場合

Ionic6 以降のバージョンでは、ion-list配下にion-itemがある場合ion-itemの role を"listitem"に上書きするようです。ion-itemをion-list配下でボタンとして使う場合、role 属性ではなく Ionic の button 属性と detail 属性をそれぞれ true、false に指定する必要がありそうです。

```html:ionic-6.html
<!-- これはrole="list"になる -->
<ion-list>
  <!-- これはrole="listitem"になる -->
  <ion-item role="button"></ion-item>
    <!-- これはrole="button"になる。detail=falseがないと、余計なアイコンが表示される -->
  <ion-item [button]="true" [detail]="false"></ion-item>
</ion-list>
```

### 遷移時、スクリーンリーダーに遷移を通知する

改善前は、画面遷移したことがスクリーンリーダーの読み上げだけでは分かりませんでした。方法は他にもありますが、改善後は[見出しにフォーカスを当てることで画面遷移が起きたことをスクリーンリーダーに通知](https://youtu.be/FsDcevSP54Q?si=nSRRJS_XJpupLT7P&t=7)しています。

画面 1 つ 1 つに見出しにフォーカスを当てる処理を書くのと大変です。前述の slides.directive.ts のような見出しにフォーカスするクラスをモーダルやRouterなど遷移のパターンごとに作り、それぞれの画面に適用しています。

```html:slides.html
<ion-slides appSlides>
  <h1 tabindex="-1">見出し</h1>
</ion-slides>
```

```typescript:slides.directive.ts
// 再掲
class SlideDirective {
  constructor(
    private inertService: InertService,
    private elementRef: ElementRef<HTMLIonSlidesElement>
  ) {}
  @HostListener('ionSlidesDidLoad')
  public onLoad(): void {
    const { inactiveSlides } = this.classifyIonSlideArray();
    inactiveSlides.forEach(inactiveSlide => {
      this.inertService.setInert(inactiveSlide);
    });
  }

  @HostListener('ionSlideDidChange')
  public onChange(): void {
    const { activeSlide, inactiveSlides } = this.classifyIonSlideArray();
    this.inertService.removeInert(activeSlide);

    const headingLevels = [1,2,3,4,5,6];
    const headingSelector = headingLevels.map((headingLevel) => `:scope > div > ion-slide.swiper-slide-active h${headingLevel}[tabindex]`).join(",");
    const heading = document.querySelector(headingSelector)
    if(heading){
      heading.focus();
    }


    inactiveSlides.forEach(inactiveSlide => {
      this.inertService.setInert(inactiveSlide);
    });
  }

  private classifyIonSlideArray(): {
    activeSlide: HTMLIonSlideElement;
    inactiveSlides: HTMLIonSlideElement[];
  } {
    const nativeElement: HTMLElement = this.elementRef.nativeElement;
    const activeSlide: HTMLIonSlideElement = nativeElement.querySelector(
      ':scope > div > ion-slide.swiper-slide-active'
    );
    const inactiveSlides: HTMLIonSlideElement[] = Array.from(
      nativeElement.querySelectorAll(
        ':scope > div > ion-slide:not(.swiper-slide-active)'
      )
    );

    return {
      activeSlide,
      inactiveSlides
    };
  }
}
```

注意点が 3 点あります。

#### 1.inert のつけ外しとフォーカスの順番

カルーセルのスライドに inert のつけ外しをしたりフォーカスを当てる場合、以下の順番である必要があります。

1. 表示しているスライドの inert 属性を削除
2. 表示しているスライドにフォーカス
3. 表示されていないスライドに inert 属性を付与

1 と 2 が逆の場合、見出しにフォーカスができません。2 と 3 が逆の場合、Android で body にフォーカスが当たってしまいます。元々フォーカスが当たっていたスライドに inert 属性が付与されることで、document.activeElement が null になってしまうからです。

#### 2.フォーカスを待つ必要がある

特定のタイミングを待ってから、見出しにフォーカスする場合があります。例えばローディングインジケーターを表示する場合、ローディングが終わるのを待ってから見出しにフォーカスします。実装が煩雑になりますしアプリの構成によって大きく変わると思いますのでサンプルコードはスキップしますが、簡単に言うとみんなの銀行では各画面から上記の Directive の<span>@</span>Input に Observable の配列を渡し、全ての配列が truthy の値を emit したタイミングでフォーカスを当てるようにしています。

#### 3.スクリーンリーダーとブラウザの組み合わせによる挙動の違い

みんなの銀行が対象としている iOS の VoiceOver と Android の Talkback では、フォーカスされた見出しを読み上げます。スクリーンリーダーとブラウザの組み合わせ次第では読み上げないこともあるので、通常の web アプリでは次項に出てくる aria-live 属性などを使ってページタイトルを読み上げる必要があります。 [^1]

### 表示内容の変更時に変更をスクリーンリーダーに通知する

改善前のアプリでは、入力エラーなど動的に変わった表示内容を読み上げないという問題がありました。[aria-live 属性を使うことで、内容変更をスクリーンリーダーに通知](https://youtu.be/FsDcevSP54Q?si=4-a2exPGmYswQEDX&t=18)することができます。

```html:validation-error.component.html
<p aria-live="polite">{{errorMessage}}</p>
```

aria-live 属性に"polite"を指定すると、それまでのスクリーンリーダーの操作を読み上げ終わってから表示内容の変更を読み上げます。"assertive"を指定すると変更された直後に読み上げることができますが、ユーザーが混乱する場合もあるので注意が必要です。

### 入力欄とラベルを紐付ける

改善前のアプリでは入力欄とラベルが適切に紐づけられておらず、どの入力欄に何を入力するか分からない箇所がありました。

標準的なマークアップを用いる場合、label 要素と input 要素を使って紐づける方法があります。

```html
<label for="name-input">名前</label> <input type="text" id="name-input" />
```

Ionic の入力コンポーネント(ion-input)を使っている箇所では、aria-labelledby を使っています。

```html
<span id="input-label">名前</span>
<ion-input type="text" aria-labelledby="input-label" />
```

これは元々のマークアップが悪く、ion-itemの配下にion-labelとion-inputを配置すれば Ionic 側で aria-labelledby を設定してくれます。

```html
<ion-item>
  <ion-label>名前</ion-label>
  <!-- Ionic側がion-labelにIDを振り、そのIDをaria-labelledbyに指定してくれる -->
  <ion-input type="text">
</ion-item>
```

このように<ion-label>を自動で aria-labelledby に指定してくれるコンポーネントは他にもあるので、意図通りにラベルを設定できない場合は Ionic のソースコードを確認してみると良いかもしれません。

### 静的解析

他にも細々とした調整を入れていますが、大半の課題は上記のようにある程度パターン化できる修正で改善できています。パターン化できる修正に関しては
[Markuplint](https://markuplint.dev/ja/)と[ESLint](https://eslint.org/)のカスタムルールで静的解析を行い、漏れがないかチェックしています。

## まとめ

みんなの銀行で行なっている修正をまとめてみました。現状みんなの銀行ではスクリーンリーダーの挙動を中心に改善を加えていますが、スクリーンリーダーでの読み上げだけがアクセシビリティではありません。より広い範囲の改善については、現在行っている改善と並行して検討を進めているところです。そちらについても今後記事にできればと思っています。

上記改善点と改修内容は、2022 年度に開催した社内勉強会で[株式会社ディーゼロ](https://www.d-zero.co.jp/accessibility/)の平尾優典氏にいただいたアドバイスがベースとなっています。この場を借りて改めてお礼を申し上げます。

## 参考資料

- [What is Angular?](https://angular.jp/guide/what-is-angular)
- [Angualar at Google I/O 2023](https://blog.angular.io/angular-at-google-i-o-2023-ed800269070e)
- [Introduction to Ionic](https://ionicframework.com/docs/)
- [Apache Cordova](https://cordova.apache.org/)
- [Web アプリケーションアクセシビリティ](https://gihyo.jp/book/2023/978-4-297-13366-5)
[^1]: [Web アプリケーションアクセシビリティ](https://gihyo.jp/book/2023/978-4-297-13366-5) 5.8 画面遷移