Ionic5 系から 7 系にアップデートするときに起きた、アクセシビリティの問題と解決策

ゼロバンク・デザインファクトリー株式会社(ZDF)でフロントエンドエンジニアをしている長島です。

「[アプリのアクセシビリティ改善で行っていること](https://qiita.com/m-nagashima-zdf/items/eae175ca0b21828b40b4)」でも触れた通り、みんなの銀行アプリは Ionic と Angular を使っています。Ionic のバージョンは v5.1.0 を使っており、現在 v7.8.6 へのアップデートを計画中です。そのテスト中に発覚したアクセシビリティの問題と解決策についてまとめます。

主に Ionic を用いてアプリを開発している人向けの内容ですが、最後の「学びと教訓」は他の UI ライブラリを使っていても同じことが言える部分があるかもしれません。また若干冗長になりますがこれまでの修正内容が前提になる個所もありますので、適宜上記の記事を参照しています。

## 具体的な課題と解決策

### 【課題 1】ion-list > ion-item の role 指定が上書きされる

みんなの銀行では暫定的に `ion-item` に `role="button"`を指定している箇所があります([アプリのアクセシビリティ改善で行っていること > ボタンなどの要素の役割と名前を指定する](https://qiita.com/m-nagashima-zdf/items/eae175ca0b21828b40b4#%E3%83%9C%E3%82%BF%E3%83%B3%E3%81%AA%E3%81%A9%E3%81%AE%E8%A6%81%E7%B4%A0%E3%81%AE%E5%BD%B9%E5%89%B2%E3%81%A8%E5%90%8D%E5%89%8D%E3%82%92%E6%8C%87%E5%AE%9A%E3%81%99%E3%82%8B))。アップデート後の Ionic では`ion-list` > `ion-item` は `role="listitem"`が付与されるようになったため、この指定が上書きされスクリーンリーダーの読み上げなどからボタンであることが分からなくなってしまいました。

#### 課題 1 の解決策

`ion-item` の `button` というプロパティ([ion-item > Properties > button](https://ionicframework.com/docs/api/item#button))を指定することで、 `ion-item` の下に `button` 要素がレンダリングされるようになります。矢印アイコンもレンダリングされますが、不要な場合は合わせて `detail=false`([ion-item > Properties > detail](https://ionicframework.com/docs/api/item#detail))を 指定します。

```html
<ion-list>
  <ion-item button detail="false"></ion-item>
</ion-list>
```

ネイティブの HTML 要素を用いることで、role 属性がなくともスクリーンリーダーなどでボタンであることが伝わります。

### 【課題 2】連続してモーダルを閉じると、ルートが aria-hidden のままになる

みんなの銀行はモーダルを多用する構成になっています。モーダルを表示している間、その裏に表示している内容を支援技術から隠すという処理を入れています([アプリのアクセシビリティ改善で行っていること > モーダルの後ろにある内容を隠す](https://qiita.com/m-nagashima-zdf/items/eae175ca0b21828b40b4#%E3%83%A2%E3%83%BC%E3%83%80%E3%83%AB%E3%81%AE%E5%BE%8C%E3%82%8D%E3%81%AB%E3%81%82%E3%82%8B%E5%86%85%E5%AE%B9%E3%82%92%E9%9A%A0%E3%81%99))。

Ionic6 系 ではモーダルの裏側に `aria-hidden="true"`を設定するという類似の処理が入りましたが、モーダルを複数立ち上げた場合の考慮がされていませんでした。そのため複数モーダルを立ち上げた後にそのうちの一部を閉じると、まだモーダルが残っているにもかかわらず `aria-hidden="true"`の指定が外れてしまい、モーダルの裏側が支援技術から参照できるという問題がありました。GitHub の issue でモーダルが 1 つしかない場合に限り `aria-hidden="true"`を外すという修正案を提案し、概ね提案通りに修正してもらえました([bug: ion-router-outlet not marked aria-hidden when multiple overlays are presented and some dismissed](https://github.com/ionic-team/ionic-framework/issues/28180))。

しかし今度はその提案にライフサイクルの考慮が足りていなかったために、別の問題が起きてしまいました。

- フォーム入力モーダル(A)を立ち上げる
- A の閉じるボタンが押下される
- 入力内容を破棄して良いかの確認モーダル(B)を立ち上げる
- B で OK が押下される
- B を閉じる
- A を閉じる

というフローがあった際に、B の`willDismiss`で A を`dismiss`した場合、モーダルの数を正しく数えられず `aria-hidden="true"`が残ったままになってしまいます。モーダルがなくなっても支援技術から画面が隠れている状態になるので、後続の操作ができなくなってしまいます。

#### 課題 2 の解決策

Ionic のコードではモーダルの数を数えるのに`overlay-hidden`というクラスが使われています。そのつけ外しタイミングがライフサイクルと嚙み合っていないので、全く別のフラグを立てるという[修正案](https://github.com/ionic-team/ionic-framework/compare/main...nagashimam:ionic-framework:root_remains_aria-hidden_after_dismissing_multiple_overlays_consecutively)を GitHub の issue で提案しました([bug: root remains aria-hidden after dismissing multiple overlays consecutively](https://github.com/ionic-team/ionic-framework/issues/29396))。どう嚙み合っていないかは若干込み入っているので割愛しますが、気になる方は GitHub の issue を参照するかコメント欄で質問してください。

Ionic7 系 を fork し修正案を適用すると事象が解決することを確認していますが、この issue はまだ`needs: investigation`というラベルがついたままでコアチームの調査中です。修正案を適用する際はその点にご留意ください。

### 【課題 3】iOS の VoiceOver でクリアボタンがある入力欄を操作すると、入力欄からフォーカスが外れなくなる

`ion-input`に`clearInput`を指定すると、入力をクリアするボタンがレンダリングされます。([ion-input > Properties > clearInput](https://ionicframework.com/docs/api/input#clearinput))。このクリアボタンに iOS の VoiceOver でフォーカスすると、入力欄にフォーカスが戻ってしまいます。

VoiceOver でクリアボタンにフォーカスすると、`ion-input`内のクリアボタンに対して`focusin`イベントを発火します。`focusin`イベントはバブリングを行うので、`ion-input`にイベントが伝播します。[`ion-input`の`focusin`](https://github.com/ionic-team/ionic-framework/blob/v7.8.5/core/src/utils/input-shims/hacks/scroll-assist.ts#L135)では`ion-input`内の入力欄にフォーカスを当てているため、VoiceOver でクリアボタンにフォーカスするたびに入力欄にフォーカスが戻されてしまうという挙動になっていました。

#### 課題 3 の解決策

Ionic に GitHub の issue で問題を報告([bug: unintended re-focus to input after selecting clear input button with iOS VoiceOver](https://github.com/ionic-team/ionic-framework/issues/29358))したところ、[クリアボタンの`focusin`イベントのバブリングを止める修正](https://github.com/ionic-team/ionic-framework/pull/29366/files#diff-a39147111a459c7d50e285f51eddf2d3fee983bda67654c842a2c645565af6bcR785)を入れてくれました。8 系を使っている人は、v8.0.1 以降のバージョンを使えばこの事象は起きません。

v7.8.6 にもバックポートが入っていますが、その際に Ionic7 系では`ion-input`のレンダリング処理に 2 種類ある点の考慮がされていませんでした。新しいシンタックス(参考:[ion-input > Migrating from Legacy Input Syntax](https://ionicframework.com/docs/api/input#migrating-from-legacy-input-syntax))を使っている場合には正しく修正が適用されていますが、古いシンタックスを使っている場合は引き続き同じ事象が起きます。みんなの銀行では古いシンタックスを使っているため、 v7.8.6 を fork し、後者にもクリアボタンの`focusin`イベントのバブリングを止める処理を入れています。

### 【課題 4】画面の表示内容を一度に全部読み上げる

`ion-slides`が Ionic6 系で非推奨になり、Ionic7 系で削除されました([Migrating from ion-slides to Swiper.js](https://ionicframework.com/docs/angular/slides))。私たちも今回`Swiper.js`に移行することにし、何点か調整が必要になりました。

そのうちの 1 つが各スライドの読み上げの粒度です。アップデート前はスクリーンリーダーがフォーカスしている箇所のみ読み上げていたのですが、アップデート後はスライドに含まれる内容を全て読み上げるようになりました。

この挙動が問題にならないアプリもあると思いますが、みんなの銀行では画面遷移をスライドで実装している箇所が多くあります。その箇所ではスライド=画面であるため、何か操作をするたびに画面全体を読み上げるという状態になってしまいました。

#### 課題 4 の解決策

こちらの原因は`swiper-container`のデフォルトの role が group になっていることが原因でした。`a11y-slide-role="general"`を指定し role を上書きすると、スクリーンリーダーでフォーカスが当たっている箇所のみを読み上げるようになりました。

## 学びと教訓

今回のアップデートを通して大きく 3 つの教訓を得ることができました。

### 大きな UI の変更が入る際には、できるだけ早いタイミングから支援技術での確認を行う

iOS の VoiceOver と Android の Talkback での動作確認は、Ionic7 系 へのアップデート作業が始まったのとほぼ同じタイミングで始めました。まだ改善するべきところは残っていますが、結果として上記のような致命的な問題の解決はなんとかリリースに間に合わせることができました。これよりも遅い段階で支援技術での確認を始めていたら、上記の問題の原因特定と対応が間に合っていなかったかもしれません。

なかなか作業の時間を確保するのが難しい場合もありますが、UI を大きく変える場合にはできるだけ早いタイミングで支援技術での確認が必要だと改めて認識しました。

### 機械的に適用できる修正には静的解析のルールを作る

前回の記事で触れていた通り、パターン化できる改善は以前から Markuplint や ESLint を用いて静的解析を行っていました。「ion-list > ion-item の role 指定が上書きされる」のような影響範囲が多くの画面に及ぶ問題もありましたが、ESLint のルールを微調整することで自動で修正することができました。アクセシビリティの改善は現時点では人間の目で見る必要がある部分が多くありますが、それらに取り組む時間を捻出するためには自動化できる部分を自動化していく必要があります。

### 標準的な HTML やライブラリ機能で出来ることはそちらを使い、なるべく独自実装をしない

`button`要素ではなく`role="button"`を使っている箇所など、開発速度を優先し抜本的なマークアップの改善をするのではなく WAI-ARIA の指定などで改善を行った箇所で修正が必要になりました。特に上記の「ion-list > ion-item の role 指定が上書きされる」で触れた書き方は Ionic5 でも同様の書き方ができたので、`role="button"`ではなく最初からそちらのプロパティを使うべきでした。

また、前回の記事の「[モーダルの後ろにある内容を隠す](https://qiita.com/m-nagashima-zdf/items/eae175ca0b21828b40b4#%E3%83%A2%E3%83%BC%E3%83%80%E3%83%AB%E3%81%AE%E5%BE%8C%E3%82%8D%E3%81%AB%E3%81%82%E3%82%8B%E5%86%85%E5%AE%B9%E3%82%92%E9%9A%A0%E3%81%99)」で触れた処理は上述の通りほぼ同等の処理が Ionic に入りましたし、「[遷移時、スクリーンリーダーに遷移を通知する](https://qiita.com/m-nagashima-zdf/items/eae175ca0b21828b40b4#%E9%81%B7%E7%A7%BB%E6%99%82%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%83%AA%E3%83%BC%E3%83%80%E3%83%BC%E3%81%AB%E9%81%B7%E7%A7%BB%E3%82%92%E9%80%9A%E7%9F%A5%E3%81%99%E3%82%8B)」の内容についても類似の機能が Ionic8 系で Experimental になりました([Managing Focus](https://ionicframework.com/docs/developing/managing-focus?__hstc=13779304.c5733ae7b2b32bdbecd09c5c31e08292.1715065840301.1715651240632.1715911203062.5&__hssc=13779304.1.1715911203062&__hsfp=3233157773&_gl=1*1ecb198*_gcl_au*MTAwNDIyOTQ5MC4xNzE1MDY1ODM4*_ga*MTQ4MjcxODIuMTcxNDExMjg3NA..*_ga_REH9TJF6KF*MTcxNTkxMTIwMS4xMC4wLjE3MTU5MTE3MTYuMC4wLjA.#assistive-technology-focus-management))。

結果論にはなってしまいますが、標準的な HTML の挙動や Ionic に入れ違いに実装された機能に近いことを自前で実装してきていた結果、余計な工数がかかってしまいました。まず標準の HTML・Ionic コンポーネントを使い、それでも出来ないことがあれば独自実装するのではなく Ionic にコントリビュートした方が、却って少ない工数で同等の改善がリリースできていたかもしれません。

## まとめ

以上、Ionic を v5.1.0 から v7.8.6 にアップデートする中で見つけたアクセシビリティ関連の問題と解決策についてまとめました。Ionic や他の UI フレームワークの更新をする方の参考になれば幸いです。