🦽

今からでも遅くない!誰も教えてくれなかった React とアクセシビリティーの世界

2021/01/13に公開
1

この記事は Front-End Study #3 で発表されたライブコーディングの内容を記事にしたものです。記事中のソースコードは GitHub でご覧いただけます。

この記事は、これまで一般的なフロントエンドエンジニアだった私が一年ほどアクセシビリティーについて勉強する上で 「最初に教えてくれればよかったのに〜!」と思った内容 を React と Next.js を用いて紹介するものとなっています。

読み終わった後に次にコードを書く際にふと意識できるようなアクセシビリティーの普遍的な事実を紹介し、最後に今後の React の動きについて軽く触れるものになっています。目次は次のとおりです:

  1. 基本事項
  2. SPA のルーティングによる問題
  3. リッチなコンポーネントでの例
  4. Jest + React Testing Library でのテスト
  5. Reactとアクセシビリティーの今後の動き
  6. 役に立つwebサイト

アクセシビリティーへようこそ

はじめに、Webアプリを作る上での基礎的なアクセシビリティーの知識についておさらいします。

セマンティック

セマンティック (semantic) とは HTML の各要素に与えられる意味のことです。ご存知の方も多いかとは思いますが、例えばクリック可能な要素は div よりも button を使うべきです。

<div onClick={() => ...}>送信</div>       // ❌
<button onClick={() => ...}>送信</button> // ✅

JavaScript を用いるとピュアな HTML では実現できないようなイベントハンドリングが可能になり、 div のような元は非インタラクティブな要素にも click イベントハンドラを付けることができるようになります。では、なぜ画面に表示される内容は変わらないのに button を使う必要があるのでしょうか?

それは支援技術がユーザーに情報を伝えるときのヒントが失せてしまうからです。例えば divTab キーを押してもデフォルトではフォーカスできませんし、仮にフォーカスできるようにしたとしてもスクリーンリーダーはボタンである旨を伝えてくれません。一方で button を使えば「送信、ボタン」のように読み上げられ、要素の正しい意味が伝えられます。セマンティクスについての詳しい解説は以下の MDN の記事が参考になります。

https://developer.mozilla.org/ja/docs/Learn/Accessibility/HTML#good_semantics

スタイリング

この記事では React を使った例を示すため CSS への言及は少なくなってしまいますが、例えば以下のような利用者と対応が考えられます。

  • キーボードユーザー ― Tabキーを使って移動するので、インタラクティブな要素に対して :focus 疑似クラス でアウトラインなどを付ける必要がある
  • 色覚特性のユーザー ― 背景色と前景色の色コントラスト比が十分である必要がある
  • 拡大機能のユーザー ― ブラウザの機能でページを拡大した際に position: fixed などで画面上に固定された要素が他の要素を妨げないようにする。

MDNの記事に更に詳しい言及と具体例があります。

https://developer.mozilla.org/ja/docs/Learn/Accessibility/CSS_and_JavaScript

アクセシブルな名前の設定

アクセシブルな名前(accessible names) とは HTML の要素に付けられた人間が読める名前です。名前には様々な指定方法があります。

{/* 画像に "きれいな写真" と名付ける */}
<img src="/picture.png" alt="きれいな写真" />

{/* アイコンだけのボタンに "送信" と名付ける */}
<button aria-label="送信"><svg ... /></button>

{/* inputに "氏名" と名付ける */}
<label htmlFor="fullname">氏名</label>
<input id="fullname" type="text" name="fullname"/>

{/* 画面に表示される説明がある場合は、 id で紐付けることができます。 */}
<nav aria-labelledby="nav-title">
  <h2 id="nav-title">ナビゲーション</h2>
</nav>

画像やアイコン、CSSによる装飾はすべてのユーザーが利用できるわけではありません。それらに頼っている場合は常に画像やCSSが適用されない状態を想像して、説明が必要な箇所にはこうしたアクセシブルな名前で補う必要があります。

ランドマークと見出し

加えて、 HTML には main header footer などサイトの構造をマークアップするのに役立つ区分化要素があります。以下のような例をご覧ください。

<body>
  <header aria-label="ヘッダー">
    <h1 id="title">私のサイト</h1>
    <nav aria-labelledby="nav-title">
      <h2 id="nav-title">ナビゲーション</h2>
      <ul>...</ul>
    </nav>
  </header>
  <main aria-label="本文">
    <article aria-labelledby="article-title">
      <h2 id="article-title">ポラーノの広場</h2>
      <p>あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら</p>
    </article>
  </main>
  <footer aria-label="フッター">
    Copyright 2021
  </footer>
</body>

これらの要素を使うと何が嬉しいのでしょうか?例えばスクリーンリーダーの場合、ユーザーは各ノードを上から下へと選択し、音声を聴きながら目的の要素を探します。特に何もしていない場合、既に訪問したことがあるサイトで何度もヘッダーの内容を聴く必要が生まれてしまいます。

こんなときに区分化要素を使っていればメニューから各部に直接ジャンプできるため、逐一音声を聴きながら目的の要素を探さずに済みます。 macOS 標準の VoiceOver では、以下のように解釈されます。

VoiceOverのlandmarkとheadingメニューのスクリーンショット

また、同様にして h1h6といった見出しにもジャンプできます。WebAIMが行ったアンケートによると、スクリーンリーダー利用者の70%近くがランドマークよりも見出しを使うという結果が出ているため、ランドマークと併せて見出しも設定するようにしましょう。

隠し要素

見出しやラベルを置きたいけれど、デザイン上意味が自明で置くと返って邪魔になってしまうという場合は、aria-label の他にCSSで画面からは隠しつつ支援技術には伝えるテクニックがあります。例えば Twitter ではプロフィールのツイート一覧の直前、左上に隠れた h1 が挿入されています。

VoiceOverでTwitterのタイムラインを表示し、隠れている見出し要素をフォーカスしているスクリーンショット

このようなテクニックは visually-hidden (見た目は隠されている=セマンティック上は存在する)と呼ばれており、次のようなCSSで実装されることが多いです。

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

クライアントサイドのルーティング

ここからは React などの SPA 特有のアクセシビリティーの問題について紹介します。

従来のウェブサイトはブラウザの機能でページを遷移すればフォーカスやスクロールが元に戻りましたが、 SPA では実際のページ遷移ではなく History API を使って擬似的に複数ページを実現しているため、ページが遷移していてもフォーカス位置がリセットされません[1]

スクリーンリーダーやキーボードは各ノードを上から下へと選択しながら移動するため、このような SPA 特有の振る舞いはアクセシビリティー上の問題になり、ユーザーが文脈を理解しづらくなってしまいます。こうした問題を解決するため、ここではページ遷移時のフォーカスマネージメントとアナウンスを実装します。

次のように、ナビゲーションに /blog/ へのリンクがありクリックするとクライアントサイドでルートが変更さるような Next.js アプリを考えてください。

一般的なウェブサイトのモックアップ。ポートフォリオ風のサイトに自己紹介が書かれているスクリーンショット。

components
└── Layout.tsx
pages
├── _app.tsx
├── blog.tsx
└── index.tsx

pages/_app.jsx

先に述べた問題を解決するために、まずここにページが変更された際に子要素にフォーカスする処理を実装します。Reach Router では同様の機能がデフォルトで実装されています。

_app.jsx_document.jsx は Next.js のライフタイムでアンマウントされない唯一のコンポーネントなので、ルート変更時のフックが記述できます。useRouter でコンテキストから Next.js のルーターを取得でき、 Router#events からはページ変更などの各種イベントをリッスンできるので、そのルート変更時のイベントを拾って id="main" が付いたノードにフォーカスを当てましょう。

const App = ({ pageProps, Component }) => {
  const router = useRouter();
  
  const handleRouteChange = useCallback(() => {
    const main = document.getElementById('main');
    main.focus();
  });
  
  useEffect(() => {
    router.events.on('routeChangeComplete', handleRouteChange);
    return () => router.events.off('routeChangeComplete', handleRouteChange);
  }, []);

  return (
    <Component {...pageProps} />
  );
}

components/Layout.tsx

各ページ共通の要素(ヘッダー、ラッパー、フッター)をまとめたレイアウトのためのコンポーネントです。次のポイントに注目してください。

  1. ページの最上部にスキップリンクを挿入して、既にページを訪問したことがある場合などに本文までジャンプできるリンクを挿入します。キーボードなどでも使えるように Tab を一度押してフォーカスした際に見えるようにします。
  2. JSから <main> にフォーカスが当てられるように、id 属性と tabIndex="-1"を指定しています。
  3. ページの最下部にCSSで非表示な要素を置き、そこにページ変更時のアナウンスとaria-live="assertive" を指定することで、クライアントサイドでルートが変更されたときにその旨を伝えます。SPAにおけるこの手法は Gatsby でもデフォルトで採用されています。
const Layout = ({ children, title, description }) => {
  return (
    <div id="app">
      <Head>
         <title>{title}</title>
	 <meta name="description" content={description} />
      </Head>
      
      <a href="#main" className="sr-only focus:not-sr-only"> // ①
        本文にスキップ
      </a>
      
      <header>ヘッダー</header>
      
      <main id="main" tabIndex={-1}> // ②
        {children}
      </main>

    <footer>フッター</footer>
    
      // ③
      <div
        aria-atomic
	aria-live="assertive"
	className="sr-only"
      >
        {title}を閲覧中
      </div>
    </div>
  );
}

pages/index.jsx

ここまでで用意した _app.jsxLayout を組み合わせることで、タイトルと説明文を渡すだけでアクセシブルにするための処理を共通化できます。

const Home = () => {
  return (
    <Layout title="ホーム" description="私のホームページです">
      コンテンツ
    </Layout>
}

実際に動作している様子は下記のGIF画像をご覧ください。ナビゲーションのリンクをクリックし遷移するとアナウンスが表示され、本文にフォーカスが移っています。

上記のコードをローカル環境で動作させた動画。ナビゲーションにフォーカスし、2つのページを遷移

リッチなコンポーネント

React によってインタラクティブなコンポーネントを作れば高い UX を実現できる反面、様々なユーザーエージェントに関する正しい理解がないとアクセシブルでないアプリを作ってしまうことがあります。例としてボタンをクリックすると表示される次のようなモーダルウィンドウを考えてください。

ボタンを押すと開くモーダルウィンドウのスクリーンショット

ウィンドウ

画面中央の要素で、タイトルと本文を持ちます。下記のポイントに注目してください。

  1. 見出しに ref を渡してマウント時にフォーカスが当たるようにしています。これによってボタンを押した直後にモーダルの h2 に対してフォーカスが移動し、ユーザー自身がモーダルを探す必要がなくなります。
  2. また、role="dialog" でラッパー要素がダイアログであることを示し、aria-modal によって背後の要素が非インタラクティブであることを明示します。
  3. タイトルを示す h2 に対して id を指定し、ラッパー要素に aria-labelledby を指定することでラッパーにラベルを付けています。
const Window = ({ title, children }) => {
  const ref = useRef(null);
  
  useEffect(() => {
    ref.current.focus(); // ①
  }, []);

  return (
    <div
      role="dialog" // ②
      aria-modal
      aria-labelledby="modal-title" // ③
    >
      <h2 id="modal-title">
        <a ref={ref} href="#modal-title">{title}</a>
      </h2>

      <div>{children}</div>
    </div>
  );
});

コンテナ

ウィンドウとバックドロップ(背景の影)をレンダリングします。下記のポイントに注目してください。

  1. バックドロップはクリックすることで閉じることができます、これのキーボード向けの代替として Escape キーで同様のことができるようにしています。
  2. aria-modalに対応していないユーザーエージェントのために、マウント時に #app に対して aria-hidden を設定することでバックドロップより後ろの要素へのインタラクションを防いでいます。
const Modal = ({ title, children, onClose }) => {
  const handleKeydown = useCallback((e) => { // ①
    if (e.key === 'Escape') onClose();
  });

  useEffect(() => {
    document.getElementById('app').setAttribute('aria-hidden', 'true'); // ②
    document.body.addEventListener('keydown', handleKeydown);
    
    return () => {
      document.getElementById('app').removeAttribute('aria-hidden');
      document.body.removeEventListener('keydown', handleKeydown);
    };
  }, []);

  return createPortal(
    <div className="backdrop" onClick={onClose}>
      <Window title={title}>{children}</Window>
    </div>,
    document.body,
  );
};

開くボタン

モーダルを開くボタン Opener を考えます。useState で真理値を持ち、ボタンをクリックするとコールバックで状態を書き換えます。開く際は onClick でそのままセットしますが、モーダルを閉じたあとにフォーカスがページ先頭に戻るのを防ぐために、閉じる際のハンドリングでボタンのrefにフォーカスを戻していることに注目してください。

const Opener = () => {
  const [show, setShow] = useState(false);
  const buttonRef = useRef(null);
  
  const handleClose = () => {
    setShow(false);
    buttonRef.current.focus(); // 注目
  }
  
  return (
    <>
      {show && (
        <Modal title="タイトル" onClose={handleClose}>
	  こんにちは
	</Modal>
      )}

      <button ref={buttonRef} onClick={() => setShow(true)}>
        開く
      </button>
    </>
  );
};

実際に動作する様子は以下のGIF画像をご覧ください。開くボタンを押した後にモーダルウィンドウの見出しにフォーカスが移動し、 閉じると元の開くボタンにフォーカスが戻っていることがわかります。

モーダルウィンドウのVoiceOverでの実演


さて、ここではモーダルの具体例について紹介しましたが aria-*role といった見慣れない属性が出てきて当惑されている方も多いかと思います。しかし、個人的にはひとつひとつ調べるよりも W3C の WAI-ARIA Authoring Practice日本語版)を参考に実装することをおすすめします。

実際、ARIA属性は全部で50種類、Role属性値は80種類近くあり、すべてを把握し使いこなすのは至難の業です。Authoring Practice にはチェックボックス、カルーセル、さらに読むボタン、アコーディオンなどの例が数十種類あり大抵のユースケースはカバーできるので、最初はそれを参考にしつつ、慣れたら自分で調べ始めるのが良いかもしれません。

Jest + React Testing Library によるテスト

アクセシビリティーの薫陶を受けることができるのはスクリーンリーダーなどの支援技術だけではありません。ここでは、アクセシビリティーを改善することでマシーンリーダビリティーも向上し、結果としてテストが書きやすくなるという例を見ていただきます。

Modal.spec.tsx

上で紹介したモーダルを例にテストを書くことを考えます。アクセシビリティーに考慮したコンポーネントに対してテストを書く際にオススメなのは React Testing Library というテストフレームワークです。次のアサーションに注目してください。

  1. モーダルを開くボタンをレンダリングし、「スキルを表示する」という名前が付いたボタンをクリック
  2. 「私のスキル」という名前のダイアログが画面に映り、「私のスキル」という要素がフォーカスを持っている
  3. 「閉じる」という名前のボタンをクリックするとダイアログが消え、「スキルを表示する」という名前のボタンにフォーカスが戻る
describe('Modal', () => {
  it('is accessible', () => {
    // ①
    render(<Opener />);
    fireEvent.click(screen.getByRole('button', { name: 'スキルを表示する' }));

    // ②
    expect(screen.getByRole('dialog', { name: '私のスキル' })).toBeVisible();
    expect(screen.getByText('私のスキル')).toHaveFocus();

    // ③
    fireEvent.click(screen.getByRole('button', { name: '閉じる' }));    
    expect(screen.queryByRole('dialog', { name: '私のスキル' })).toBeNull();
    expect(screen.getByRole('button', { name: 'スキルを表示する' })).toHaveFocus();
  });
});

getByRoletoHaveFocus などは見慣れないAPIかもしれませんが、これは React Testing Library によって提供される函数です。他にも、getByTitle getByAlt getByLabelText のような函数で要素を探し出し、 toBeVisible toHaveAttribute などのアサーションでテストを記述します。

React Testing Library は Enzyme のIDやクラス名を使って具体的な実装に注目するテストとは対照的に、こうしたツールを提供することで振る舞いに関してのテストが書きやすくなるように設計されています[2]。これに慣れればビヘイビア駆動開発をフロントエンドでも行えるようになり、DOMの状態をテストしたりスナップショットを取るよりもさらに本質的なテストが書けるようになるでしょう。

さらに、React Testing Library のセレクターはテストの対象となる要素がアクセシブルな名前を持っている前提になっており、アクセシビリティーを考えた開発と非常に相性が良いです。画像やランドマークを対象にテストを行うときも getByAltgetByLabelText で要素を探す必要があり、自ずとスクリーンリーダーなどの支援技術が要素を見つけるのと全く同じ方法でテストも記述することになります。すなわち、アクセシビリティーを良くするとテストが書きやすくなります[3]。(その逆も然り)

Reactとアクセシビリティーの今後の動き

この記事を書くにあたってリサーチした情報を基に今後起こりそうな動きについて知っている限り書いておこうと思います。

useOpaqueIdentifier

さて、id 属性による aria-labelledby を使って再利用可能なデザインシステムを作ることを考えてみてください。

const BlogPost = () => (
  <article aria-labelledby={id} aria-describedby={anotherId}>
    <h3 id={id}>{title}</h3>        //
    <p id={anotherId}>{content}</p> // このIDたちをどうやって設定する?
  </article>
);

HTMLの id 属性はwebページ中で重複してはならないため、単純に id="blog-post-title"のように書いてしまうと、Clean Architecture 風に言えばアプリケーション層の知識がドメイン層に漏れていて <BlogPost /> を複数箇所で使いたくなった際に困ります。

では、 prop から id を受け取るのはどうでしょうか?

const BlogPost = ({ titleId, descriptionId, title, description }) => (
  <article aria-labelledby={titleId} aria-describedby={descriptionId}>
    <h3 id={titleId}>{title}</h3>
    <p id={descriptionId}>{content}</p>
  </article>
);

// 利用側
<BlogPost
  title="My Title"
  titleId="my-title"
  description="Here's my description"
  descriptionId="my-descritpion"
/>

コンポーネント側では重複の可能性を考慮しなくてよくなりましたが、利用側で個々のIDを与えなくてはならず、せっかく共通のコンポーネントにロジックを閉じ込めたのにID管理という手間が生じてしましました。

さらにはこの程度のシンプルな例なら良いものの、Atomic Design のように小さなコンポーネントを組み合わせてさらに大きなコンポーネントを作るとなると、IDのバケツリレーが発生し、IDを管理するためのContextを作る...のような惨事になりえます。

facebook/react#17322 で追加された新しいhookである useOpaqueIdentifier[4] はこのような aria-labelledby のためにランダムな文字列を生成することで問題を解決する hook です。ただのランダムな文字列生成なら hook である必要はありませんが、 SSR した際にクライアント側のレンダリングとIDが異なるものになった際に属性値が正しく更新されることが保証されています。

const BlogPost = ({ title, description }) => {
  const titleId = unstable_useOpaqueIdentifier();
  const descriptionId = unstable_useOpaqueIdentifier();
  
  return (
    <article aria-labelledby={titleId} aria-describedby={descriptionId}>
      <h3 id={titleId}>{title}</h3>
      <p id={descriptionId}>{content}</p>
    </article>
  );
};

このhookはまだ unstable で、experimental タグでしか公開されておりませんが、「aria-labelledby 属性のために React本体にランダム文字列を生成する函数を追加する」という事実から React チームのアクセシビリティーに対する本気度を伺えます。

React Flare と FocusScope

ここまでのコードサンプルをご覧になった方であればお解りかと思いますが、フォーカスマネージメントを正しく行うためには refdocument.querySelector といった、 Reactが本来隠蔽している直の DOM 操作を大量に使わなければなりません。このままでは React が提唱している宣言的UIが台無しになってしまっています。

React Fire という前身から派生した React Flare というコードネームの機能は、バンドルサイズを圧迫する、バブル・キャプチャフェーズが使えない、カスタムイベントが利用できない、 React Native と React DOM で非互換、などの問題があった SyntheticEvent を解決するために進行していたプロジェクトですが、これに乗じてこれまで煩わしかったフォーカス処理も使いやすくしてしまおうという issue が立ち上がっています。

https://github.com/facebook/react/issues/16009

主に提案されているのは次の機能です[5]

  • <FocusScope /> ― ネイティブの Element.focus は実質的にグローバルで、再利用可能なコンポーネントとは本質的に相性が悪い。 フォーカスをスコープ化して各々のコンポーネントがフォーカスを宣言し、 React がそれを順位付けして実際にフォーカスできるようにする。これによってモーダルを閉じたら元の場所にフォーカスが戻るなどの処理が簡単にできるようになる。
  • ロービングタブインデックス ― リスト内の要素を移動するときと、リスト外を移動するときに何度も tab を押さずに済むように WAI-ARIA Practices で紹介されているようなtabと矢印キーを組み合わせた操作をフレームワークでサポートする。
  • キーボードトラップ ― この記事で紹介したモーダルの背後の要素を aria-hidden で隠し、モーダル外に出られなくするような機能をフレームワークでサポートする

この React Flare と Flare で実装する FocusManager のissueは残念ながら抽象化が高度すぎるとして close されてしまいましたが、 FocusManager の RFC は議論が進んでおり、現在でもモノレポ内に react-interactions というパッケージがあり、別の形で引き続き開発が続いているようです。今後の動きに期待しましょう。

役に立つwebサイト

私がよく参照するウェブサイトです。

  • WAI-ARIA Authoring Practice日本語版)― この記事のモーダルの例のような具体的なコンポーネントを、(プレーンなHTMLとJSでですが)そのアクセシブルな例とともに数種類紹介しています。GitHubリポジトリ上での議論も併せて見ておくと理解が深まります。
  • WebAIM Survey ― WebAIMという団体が支援技術の利用者に対してアンケートを行った結果をまとめています。例えば「ランドマークよりも見出しを使う人が多い」など、普段からスクリーンリーダーを使っている人でないとわからないような貴重な情報を見ることができます。
  • accrefs ― アクセシビリティーに関する日本語の資料などを集めたリンク集です。
  • A11YJ(Slack) ― アクセシビリティーに関するQ&Aなどが行われている日本語のSlackワークスペースです。
  • MDN「アクセシビリティー」 ― W3Cの文章よりも全体的に開発者寄りなアクセシビリティーの知識について紹介しています。日本語訳が充実しています。MDNはこれ以外にも各HTML要素のページにアクセシビリティーについての言及があります。
  • React「アクセシビリティー」 ― React 公式ドキュメントのアクセシビリティーに関する言及です。記事は短いですが、SPAで犯しがちなミスについて説明されています。
  • Gatsby Blog ― Gatsbyの公式のブログでSPAのアクセシビリティーのベストプラクティスやテスト方法についてかなり詳しく解説されています。
  • /r/accessibility ― アクセシビリティー全般の subreddit です。かなりアクティブに議論が行われています。
  • Web Accessibility Tutorials ― Authroing Practice よりも基礎的なマークアップやアクセシビリティーの知識について紹介しています。時間があるときに通して読むと良いと思います。
  • Accessibility Support ― Caniuse.comのスクリーンリーダー版です。WAI-ARIA の RFC 2119 キーワードに対してどの程度準拠しているかという情報を元に各スクリーンリーダーとメジャーなブラウザーの組み合わせで表を提供しています。
脚注
  1. 関連issue https://github.com/vercel/next.js/issues/7681 ↩︎

  2. The more your tests resemble the way your software is used, the more confidence they can give you. https://testing-library.com/docs/react-testing-library/intro/ ↩︎

  3. This library encourages your applications to be more accessible and allows you to get your tests closer to using your components the way a user will ↩︎

  4. useOpaqueIdentifierについて詳しく解説されている日本語の記事はこちらをご覧ください。 https://www.dkrk-blog.net/react/useopaqueidentifier ↩︎

  5. https://github.com/reactjs/rfcs/issues/104 ↩︎

Discussion

kuboshokubosho

@testing-library/react を使ってテストを書いている場面で fireEvent を使っていますが、Firing Events | Testing Libraryを見ると @testing-library/user-event を使ったほうが良いと書かれています。

実際 user-event のほうがよりユーザーがおこなう操作をシミュレートできる形でテストを書けて、より振る舞いテストに近い形で書ける認識ですが、 fireEvent を使った理由はなんでしょうか?