📢

SPA(Next.js)のスクリーンリーダーによる画面遷移で工夫したこと

2022/02/02に公開

こんにちは、Ubie株式会社でデザインエンジニアをやっているtakanoripです。
UbieではユビーAI受診相談というサービスを開発しており、症状に関する質問をするページ(以下「質問ページ」という)を中心にアクセシビリティ向上のための様々な改善を進めています。

症状に関する質問をするページのスクリーンショット

今回はその中の1つの事例をご紹介します!

Ubieでのアクセシビリティ推進の全体像が気になる方は弊社デザイナー3284さんの記事を読んでもらえたら嬉しいです!
https://note.com/3284/n/n8c1bc619514c

今回の課題:SPAでの画面遷移

SPAの画面遷移はスクリーンリーダー対応が漏れがちなポイントとして広く知られています。
SPAでない静的なウェブサイトでページ遷移した場合、スクリーンリーダーは次ページのタイトルを読み上げ、カーソル位置はページの先頭の要素に移動します。
しかしスクリーンリーダーに対応できていないSPAでページ遷移した場合、スクリーンリーダーは次ページのタイトルを読み上げず、カーソルが不自然な位置(前画面でリンクやボタンをおした画面座標に近いところ)に移動してしまいます。
アプリケーションがどう実装されているかはユーザーにとって関係ないことなので、静的なウェブサイトと同じ挙動になっている必要があります。

2021年11月に開催されたJSconf JPのOkuto Oyamaさんのセッションでも少し触れられていましたね。
https://www.youtube.com/watch?v=58TkWIsH20E&t=804s

上の動画の中でも言及されていますが、この問題を解決するためにNext.jsではRoute Announcerという機能が公式に提供されています。Route Announcerを有効にするとページの変更を aria-live 属性を使ってスクリーンリーダーに通知できます。
https://github.com/vercel/next.js/blob/canary/packages/next/client/route-announcer.tsx

ユビーAI受診相談の開発でもNext.jsを利用しているため当初はこの機能を使えばスクリーンリーダーでも問題なくページ遷移ができると考えていました。
しかし実際に開発を進めると思わぬ問題が発覚しました。

Ubieで発生した問題

Route Announcerは次ページのタイトルを読み上げることでページ遷移したことをユーザーに知らせます。
しかし、ユビーAI受信相談ではページごとにユニークなタイトルを設定するのが難しく、タイトルがユニークでないとタイトルの読み上げだけではページ遷移したことが伝わりにくいという問題がありました。
この問題を解決するには、別の方法で次ページへ遷移したことをユーザーに知らせる必要があります。
また、Route Announcerにはスクリーンリーダーのカーソル位置を修正する機能はないため、カーソルが不自然な位置に移動してしまう問題を解決するための実装が別途必要でした。

まず最初に考えた解決策は、ページ遷移した際に次ページのh2要素にJSでフォーカスを当てるというものでした。
質問ページではページの主題がh2要素でマークアップされているので、そこにスクリーンリーダーのカーソルを移動し内容を読み上げれば、次ページに遷移したことを知らせると同時にカーソルも適切な位置にセットできると考えていました。(スクリーンリーダーのカーソルはキーボードのフォーカス位置に追従するようになっています。)

しかし、それだけでは問題は解消しませんでした。Route Announcerによるタイトル読み上げとスクリーンリーダーのカーソル移動によるh2の読み上げが衝突してしまい、タイトルの読み上げが途中で中断されh2が読まれるようになってしまいました。
また対応したのが質問ページだけだったため、それ以外のページでのスクリーンリーダーのカーソル位置はいぜん不自然なままでした。

実際に調査した結果を表にまとめると次のようになります。

対象\SR mac VoiceOver iOS VoiceOver NVDA
トップ タイトル読む
カーソル不安定
フォーカスは先頭
タイトル読む
カーソル不安定
フォーカスは先頭
タイトル読む
カーソル不安定
フォーカスは先頭
質問1問目 タイトル読む
カーソルはh2安定だが読まない
フォーカスはh2
タイトル読むが途中でh2に乗っ取られる
カーソルはh2安定で読む
フォーカスはh2
タイトル読む
カーソルはh2安定で読む
フォーカスはh2
質問2問目以降 タイトル読まない
カーソルはh2安定で読む
フォーカスはh2
タイトル読まない
カーソルはh2安定で読む
フォーカスはh2
タイトル読まない
カーソルはh2安定で読む
フォーカスはh2
質問ページ以外 タイトル読む
カーソル不安定
フォーカスは先頭
タイトル読む
カーソル不安定
フォーカスは先頭
タイトル読む
カーソル不安定
フォーカスは先頭

質問ページではJSで h2 にフォーカスを当てて読み上げさせる実装が先に入っていたためカーソル位置が安定していますが、質問1問目のページではその実装とRoute Announcerの読み上げが競合して読み上げが不安定な結果になりました。

質問ページでVoiceOverを利用した場合のスクリーンショット

解決策

今回はNext.jsのRoute Announcerを無効にし見出しにフォーカスを当てる機能を独自実装することでこれらの問題を解決しました。
Next.jsにはRoute Announcerを無効にするオプションが存在しないためCSSで aria-live 属性の指定されている要素に display: none を指定しました。

#__next-route-announcer__ {
  display: none !important;
}

フォーカスのリセットはNext.jsの _app.tsx に実装しました。
document.querySelector で見出し要素(h1, h2, h3, h4)を探索してフォーカスを移動します。見出しが見つからない場合は main 要素にフォーカスを移動させています。
実際のコードは次のとおりです。

const RootComponent: FC<{ Component: ComponentType; pageProps: any }> = ({ Component, pageProps }) => {
  ...
  const { asPath } = useRouter();
  const $main = useRef<HTMLElement>(null);

  // ページ遷移時に見出しにフォーカスを強制移動する
  useEffect(
    () => {
      const FOCUS_TARGET_SELECTOR = 'dialog, [role="dialog"], h1, h2, h3, h4';
      const TIMEOUT_TIME = 300;

      // ページ遷移直後に読込中で要素が表示されてなかったりモーダルダイアログが発生する可能性を考慮して TIMEOUT_TIME ミリ秒待機
      const timerId = setTimeout(() => {
        const focusTarget = searchFocusTarget(FOCUS_TARGET_SELECTOR);
        if (focusTarget) {
          // モーダルコンポーネントの実装が正しければフォーカスは自動的にモーダルに移動しているはず
          // なのでモーダルが発見された場合はそのまま
          if (isDialog(focusTarget)) {
            return;
          }

          focusTarget.tabIndex = -1;
          focusTarget.focus();
          return;
        }

        // 移動対象が検出できない場合は <main> にフォーカスを移動
        $main?.current?.focus();
      }, TIMEOUT_TIME);

      return () => {
        clearTimeout(timerId);
      };
    },
    // ルーティングのパスの変更によって実行される
    [asPath],
  );

  return (
    <Root>
      <main tabIndex={-1}>
        <Component />
      </main>
    </Root>
  );
};

function searchFocusTarget(selector: string) {
  const selectorList = selector.split(',');
  for (const selector of selectorList) {
    const target = document.querySelector<HTMLElement>(selector);
    if (!target) continue;
    const isShowed = !!target.getClientRects()[0]?.width;
    if (isShowed) {
      return target;
    }
  }
  return null;
}

function isDialog(element: HTMLElement) {
  return element.matches('dialog, [role=dialog]');
}

パスの変更を起点にした場合、DOMの構築が完了していないタイミングでフォーカス移動処理が実行されてしまう可能性があるため、setTimeout で300ms待機しています。
MutationObserverの利用も検討しましたが、フォーカス対象を探しているタイミングでユーザーの操作があった場合に強制的に見出しにフォーカスが戻されてしまうなどの予期せぬトラブルが生じる可能性があったため、見送りました。

この実装の課題点は、Reactの管理外の処理である(document.querySelectorを使用している)点と待機時間を超えるとフォールバックされてmain要素にフォーカスが移動してしまう点です。ページ遷移後にデータを取得するページでは見出しがあるにもかかわらずフォールバックしてしまう確率が高くなります。Reactの中で実装する案やフォールバックしない案も考えていましたが、次の理由からこの実装を採用することになりました。

  • _app.tsx で実装を完結させたかった
    • 各ページに影響がある方法だとページの洗い出しや動作確認などの作業量が膨大になってしまう
  • ユーザー影響の大きい箇所だったので、時間をかけずに対応したかった
  • リリースしてユーザビリティテストなどでフィードバックを集めつつより良い方法を模索したかった

今後の課題

上記の「ページ遷移後にデータ取得するページでフォールバックしてしまう」問題は今後解決したいです。

またユーザビリティテストを実施したことで新しい問題も見つかりました。
それは、Androidのスクリーンリーダーを利用している場合 main 要素にフォーカスが移ってしまうと本文の内容を自動的に上から連続して読み上げてしまい、操作ができなくなるという問題です。
現状では見出しが見つからなかった場合 main 要素にフォールバックするようになっているので、いくつかのページでこの問題が発生しています。
現在この問題を解消するため開発を進めています。

ページ遷移のスクリーンリーダー対応はまだまだ課題が多いので、今後もユーザビリティテストを積極的に行い一つずつ改善していきたいと考えています。

採用宣伝

Ubieではデザイナー・デザインエンジニア・エンジニアを積極的に募集しています!
https://recruit.ubie.life/jd_dev/des
https://recruit.ubie.life/jd_dev/des_eng
https://recruit.ubie.life/jd_dev/eng_prod
https://recruit.ubie.life/jd_dev/eng_frontend

アクセシビリティ推進に興味があるというデザイナー・エンジニアの方、ぜひカジュアルにお話しましょう!

Meetyもやっているので、そちらのご応募もお待ちしております!

https://meety.net/matches/nNvMCdUsITzK

https://meety.net/matches/rjQKdeAVGOxE

Ubie テックブログ

Discussion