モーダルダイアログのようなUIには、それが出現している間はダイアログの外の領域が操作不能になっているという慣習があります。ダイアログが取り扱うタスクだけにユーザーを集中させるために、あえてダイアログ以外の操作ができないようになっています。

またモーダルダイアログをはじめとしてディスクロージャーなど、ユーザーの操作に応じて表示と非表示が切り替わるUIもあります。こうしたUIは視覚的には隠れているようでも、実装としては、つねにDOM上に存在しているHTML要素の属性だけを書き換えてレンダリング結果を制御するのが一般的です。

こうしたケースでは、特定の領域を操作できないように実装を行う必要があります。CSSでdisplay:nonevisibility:hiddenが指定されたHTML要素はそれだけで操作できない状態になりますが、それ以外の方法で非表示になったHTML要素は、利用状況によっては意図しない不完全な形で操作できてしまい、ユーザーにとっての混乱や操作の非効率性を招くことがあります。

この記事ではモーダルダイアログを例に、こういった領域をいかにして「非活性」に実装するかを解説します。

フォーカス管理の要件

ウェブページ上でキーボードのTabキーを入力すると、いくつかの種類のHTML要素はフォーカスされた状態になります。具体的にはリンクのほか、ボタン・チェックボックス・テキストフィールド等のフォームコントロールなどが対象です。フォーカスされたHTML要素はキーボードによって操作可能な状態になり、文字を入力したり、リンク先にEnterキーで移動したりできます。Tabキーを入力するたびに、ページのDOM上での並び順に従って次のフォーカス可能なHTML要素へフォーカスが移動するので、Tabキーによる操作だけでサイトを巡回することもできます。キーボードでの操作に慣れたユーザーによく利用されているほか、これがなければサイトの利用に困難をきたすこともあるアクセシビリティ的にも軽視できない機能です。

このようなキーボードによる操作は、適切なHTML要素を選択してマークアップしていればある程度は担保されますが、既存のHTML要素だけでは表現できない振る舞いについては開発者が個別にケアしなければいけません。

モーダルダイアログについて言えば、フォーカスに関しては次のような要件があります。

モーダルダイアログが開いているとき:

  • 開いたタイミングで、モーダルダイアログ自体またはその中のHTML要素にフォーカスを移動させる
  • モーダルダイアログの外のHTML要素にはフォーカスされないようにする

モーダルダイアログが閉じているとき:

  • 閉じたタイミングで、モーダルダイアログが開く前にフォーカスされていたHTML要素にフォーカスする(フォーカス位置を復元する)
  • モーダルダイアログ自体またはその中のHTML要素にはフォーカスされないようにする

キーボード操作としては、Escapeキーを入力したときにモーダルダイアログが閉じられることも必要です。そのほかの詳細な要件については「3.9 Dialog (Modal) § WAI-ARIA Authoring Practices 1.2」(日本語訳)を参考にできます。

単に開いたタイミングや閉じたタイミングでフォーカスを移動させるだけであれば、HTMLElement.focus()でそれほど難しくなく実装できるのであまり問題にはなりません。一方で特定の領域にフォーカスされないようにするためにはいくつかのアプローチがあります。

フォーカストラップ

ユーザーのフォーカスが特定の領域から外に出られないようにJavaScriptで制御する「フォーカストラップ」と呼ばれるテクニックがあります。実装としては、ユーザーのキーボードイベントを監視し、Tabキーが入力されたタイミングでフォーカスされているHTML要素がフォーカストラップの領域内でのフォーカス順が最後だった場合には、その領域の外にあるHTML要素をフォーカスさせずに、領域内でのフォーカス順が最初のHTML要素にフォーカスさせるというものです。

たとえば次のような構造のHTMLがあるとき、「2」から「3」、「3」から「4」へのフォーカスの移動は通常通り行われますが、「4」の次は「5」でなく「2」に戻る。つまりフォーカス順がループするような実装になります。Shiftキーを併用しながらTabキーを入力すると前方向にフォーカスが移動しますが、その場合も同様に「2」から「1」ではなく「4」に移動します。

<body>
  <a href="1">1</a>

  <div id="dialog">
    <a href="2">2</a>
    <a href="3">3</a>
    <a href="4">4</a>
  </div>

  <a href="5">5</a>
</body>

ここで少し気になる点が通常のページとのフォーカスの移動先の違いです。というのも普通、ページ内にある最後のフォーカス可能なHTML要素がフォーカスされた状態でTabキーを入力すると、フォーカスはページの先頭でなくページの外にあるアドレスバーなどのブラウザのUIへ移動します。ですがこのフォーカストラップのテクニックを利用すると、フォーカスはページの外に出ることができません。

またスクリーンリーダーではTabキー以外の操作でもフォーカスを移動できるので、フォーカストラップの対象の外の要素にもフォーカスできてしまいます。そのため外のHTML要素のフォーカスを完全には無効にできていません。

スクリーンリーダーはコンピュータの画面上の情報を読み上げる、おもに視覚障害者が利用する支援技術と呼ばれるソフトウェアです。

tabindexの無効化

tabindex属性を指定すると、対象のHTML要素がTabキーによってフォーカス可能になるかどうかを制御できます。属性値が0の場合はフォーカス可能になり、-1の場合はフォーカス不可能になります。

tabindex="-1"を指定するとフォーカス自体ができなくなるわけではなく、Tabキーによるフォーカスが無効になるだけです。HTMLElement.focus()などによってフォーカスすることはできます。

たとえば前述したフォーカストラップの例と同じような構造のHTMLがあるとき、フォーカスの対象にしたい「2」・「3」・「4」以外の「1」・「5」にtabindex="-1"を指定すると、tabindex="-1"が指定されたHTML要素はフォーカスされなくなります。

<body>
  <a href="1" tabindex="-1">1</a>

  <div id="dialog">
    <a href="2">2</a>
    <a href="3">3</a>
    <a href="4">4</a>
  </div>

  <a href="5" tabindex="-1">5</a>
</body>

a要素はデフォルトでtabindex="0"が設定されているのと同じ状態になっているため、tabindex属性を明示的に指定する必要はありません。

このようにフォーカス対象外のHTML要素にtabindex="-1"を指定すると、結果的にフォーカストラップの意図と同様の目的が達成できて、かつページの外にもフォーカスが移動する状態を担保できます。ただしこの時点においてはまだ、スクリーンリーダーでは外のHTML要素にもフォーカスできるようになっています。

ポインターイベントの無効化

tabindex="-1"を指定してHTML要素がTabキーによってフォーカスされない状態になっても、クリックやタップなどの操作は無効化されていません。操作に反応しないように対象外のHTML要素のイベントは無効化します。

<body>
  <a
    href="1"
    tabindex="-1"
    style="pointer-events: none;">1</a>

  <div id="dialog">
    <a href="2">2</a>
    <a href="3">3</a>
    <a href="4">4</a>
  </div>

  <a
    href="5"
    tabindex="-1"
    style="pointer-events: none;">5</a>
</body>

CSSでpointer-events:noneを指定すると、そのHTML要素のポインターにまつわるイベントが発生しないようになります。リンクの場合はクリックしてもリンク先へ移動されなくなります。

モーダルダイアログの実装であれば背面に半透明のレイヤーを配置することが多く、そのレイヤーの存在によって後ろ側に配置されたHTML要素のイベントは遮断されるため、pointer-eventsプロパティを利用した対応は不要になる場合もあります。

テキストの範囲選択の無効化

ページにあるテキスト上をドラッグすると特定の範囲を選択できますが、モーダルダイアログの外側の要素も選択できてしまうのは意図に反するので、この機能も無効化します。

<body>
  <a
    href="1"
    tabindex="-1"
    style="
      pointer-events: none;
      -webkit-user-select: none;
      user-select: none;
    ">1</a>

  <div id="dialog">
    <a href="2">2</a>
    <a href="3">3</a>
    <a href="4">4</a>
  </div>

  <a
    href="5"
    tabindex="-1"
    style="
      pointer-events: none;
      -webkit-user-select: none;
      user-select: none;
    ">5</a>
</body>

user-select:noneを指定されたHTML要素はテキストの範囲選択ができなくなります。このプロパティはSafariではベンダープリフィックス付きの実装になっています

スクリーンリーダーからの隠蔽

Tabキーによるフォーカスとポインターイベントを無効化しても、スクリーンリーダーではほぼ変わりなく利用できるままです。これにはブラウザが支援技術のために生成するアクセシビリティツリーの存在が関係しています。

ウェブブラウザは視覚的なUIをレンダリングすると同時にアクセシビリティツリーを更新します
Accessibility on the web § Accessibility Object Model

おもにWAI-ARIAと呼ばれる仕様のセマンティクスにもとづいて、ブラウザによってDOMツリーとCSSから変換されたものがアクセシビリティツリーです。支援技術はOSのアクセシビリティAPIを介してアクセシビリティツリーとやり取りすることでユーザーにページの情報を提示します。多くのユーザーはページの視覚的にレンダリングされた結果から情報を取得しているのに対して、支援技術のユーザーはアクセシビリティツリーとのやり取りによってそれを行なっています。

WAI-ARIAでは、UIの種類や取り扱うテキスト情報の内容・状態・属性などを支援技術に伝えるためのセマンティクスとして、HTMLのrole属性とaria-*属性が定義されています。これらの指定はページの通常の振る舞いには影響を与えず、アクセシビリティツリーのみに作用します。

いくつかのHTML要素や属性はデフォルトでrole属性やaria-*属性と同等のセマンティクスを持ち合わせており、たとえば次の2つのマークアップは同じアクセシビリティツリーに変換されます。

<nav>...</nav>
<div role="navigation">...</div>

これらはImplicit WAI-ARIA Semantics(暗黙のWAI-ARIAセマンティクス)と呼ばれ、「ARIA in HTML」にて定義を一覧できます

目的とするrole属性やaria-*属性などのWAI-ARIAセマンティクスがネイティブのHTML要素や属性に含まれている場合、明示的にWAI-ARIAの属性を指定するのではなく、ネイティブのものを利用することが推奨されています。逆に言えばWAI-ARIAを意識せずとも、HTMLの仕様に則って普通にマークアップできていれば、ある程度のWAI-ARIAセマンティクスは担保されている状態になるということでもあります。

しかし例に挙げているモーダルダイアログの外側の実装についてはそれに当てはまりません。この実装では、スクリーンリーダーにおいてもそれらを無効化するためにaria-hidden属性を指定します。aria-hidden属性は指定したHTML要素をアクセシビリティツリーから除外し、存在しないように取り扱うためのものです。情報がまったく読み取れない状態になってしまうので、一般に利用の際には細心の注意を払ってください。

<body>
  <a
    href="1"
    tabindex="-1"
    style="
      pointer-events: none;
      -webkit-user-select: none;
      user-select: none;
    "
    aria-hidden="true">1</a>

  <div id="dialog">
    <a href="2">2</a>
    <a href="3">3</a>
    <a href="4">4</a>
  </div>

  <a
    href="5"
    tabindex="-1"
    style="
      pointer-events: none;
      -webkit-user-select: none;
      user-select: none;
    "
    aria-hidden="true">5</a>
</body>

こうしてスクリーンリーダーでもモーダルダイアログに則した操作性が実現できました。

inert属性

ここまで最小限の構造のマークアップを例に解説してきましたが、実際のページは例よりもずっと複雑になるはずです。そのためこれらの要件を満たそうとすると、かなり煩雑な実装になってしまう場合もあるでしょう。

たとえばフォーカス可能なHTML要素の属性を操作するためには、まずそれらのHTML要素のリストを取得する必要があります。フォーカス可能なHTML要素はa要素のほかにも、フォームコントロールやスクロール可能な要素などさまざまで、面倒なクエリセレクタを書くことになります。また別の用途でtabindex属性が指定されているHTML要素があった場合には、値を書き換えた後に正しく復元するためにもとの値を記憶しておかなければいけません。aria-hidden属性も仕様の難しさを踏まえると使わずに済むのが望ましいように思えます。

Tabキーによるフォーカスが可能なHTML要素を取得するtabbableというライブラリもありますが、完璧ではありません。

こうしたモチベーションがあり、これらの機能をひとまとめにしたinert属性がWICGから提案されていますChromeではフラグ付きで実装されているほか、Firefoxでの試験的実装も始まっています。このinert属性を利用すると、モーダルダイアログが開いているときは次のようなマークアップになるように実装するだけでここまで解説してきた要件が満たせます。

<body>
  <a href="1" inert>1</a>

  <div id="dialog">
    <a href="2">2</a>
    <a href="3">3</a>
    <a href="4">4</a>
  </div>

  <a href="5" inert>5</a>
</body>

またinert属性は、属性が指定されたHTML要素のみではなく、子孫にまでその性質が継承されることも大きな特徴です。そのためフォーカス可能なHTML要素だけを選択するような処理も不要で、それらの根元のHTML要素の属性値だけを書き換えれば事足ります。

<body>
  <div id="base" inert>
    <a href="a">a</a>
    <a href="b">b</a>
  </div>

  <div id="dialog">
    <a href="x">x</a>
    <a href="y">y</a>
    <a href="z">z</a>
  </div>
</body>

そしてこれはモーダルダイアログだけに限らず、CSSのdisplay:nonevisibility:hiddenを用いずにHTML要素を非表示にしたい場面においても有効です。

たとえばコンテンツを開閉するディスクロージャーでは、heightプロパティの値を操作してアニメーションを実装することが多いですが、それが閉じられているときにはdisplay:nonevisibility:hiddenも指定しなければその中のコンテンツが操作できてしまいます。これらのプロパティを指定しないのであれば、モーダルダイアログの外側と同様の処理をしてディスクロージャーの中のコンテンツの操作を無効化しなければいけません。その際にもinert属性を利用すると簡単に「非活性化」できます。

<div class="disclosure">
  <button
    class="disclosure__button"
    type="button"
    aria-expanded="false">...</button>
  <div class="disclosure__body" inert>...</div>
</div>

またドロワーなど、ページの可視領域の外からスライドするようなアニメーションを行うこともよくあります。そうした場合にも、閉じられている間はinert属性を指定しておくことで意図しない操作を防げます。

<div class="drawer" inert>...</div>

ブラウザでのinert属性の実装はまだしばらく安定しませんが、この機能を先駆けて試用できるようにポリフィルが用意されています。このポリフィルを読み込めば、ここまで紹介してきたような方法でinert属性が利用できるようになります。

WICG/inert: Polyfill for the inert attribute and property.

参考資料