モーダルウィンドウはアクセシビリティ対策が必須!ポイントを理解して対策してみよう【お問い合わせフォームを作ってみよう:アクセシビリティ対策編】

チュートリアル

はじめに

この記事は「お問い合わせフォームを作ってみよう」チュートリアルの一部であり、このチュートリアルのアクセシビリティ対策編として書かれています。

この記事では、モーダルウィンドウのアクセシビリティに関する課題と対策のポイントについて解説し、【確認画面の実装編】で制作したモーダルウィンドウの確認画面に対してアクセシビリティ対策を行っていきます。

「Escキーを押してもモーダルウィンドウが閉じない」、「フォーカス移動に制限がないのでフォーカスを見失いやすい」といった問題を解決し、スクリーンリーダーを使用しているユーザーでも扱いやすいように改善を行っていきます。

この記事の対象者

  • ウェブ制作初心者の方
  • お問い合わせページを作りたい方
  • アクセシビリティについて学びたい方

この記事で学べること

  • モーダルウィンドウのアクセシビリティについて
  • JavaScriptでキーボード操作を制御する方法

モーダルウィンドウの課題

モーダルウィンドウでの確認画面の実装は、ユーザーエクスペリエンスの向上やサーバーの負荷軽減といったメリットがあることを前回の記事で解説しました。

しかし、半面ではデザインの課題やアクセシビリティの配慮が必要であり、特にアクセシビリティを考慮しない場合は、スクリーンリーダーを使用しているユーザーが正しく情報を認識できない可能性があります。

具体的には、前回で制作したモーダルウィンドウはアクセシビリティの対策を行っていないので、モーダルウィンドウが表示されてもスクリーンリーダーが内容を読み上げることはありません。

このままでは確認画面としての役割を果たすことができないため、対策が必要になります。

モーダルウィンドウのアクセシビリティ対策のポイント

モーダルウィンドウを実装する際、アクセシビリティの観点では主に以下のような対策を行うことが望ましいです。

  • ARIAロールの設定: div 要素でモーダルを実装する場合、モーダルに ARIA ロールを設定し、モーダルであることをスクリーンリーダーに明示的に伝える必要があります。
  • フォーカス管理:モーダル内の要素をフォーカスできるようにし、スクリーンリーダーが内容を読み上げられるように対策を行います。また、Tabキーでフォーカスを移動できるようにし、モーダル外の要素へのフォーカス移動を制限します。
  • キーボード操作:スクリーンリーダーを使用するユーザーは基本的にマウスを使わないため、キーボードのみの操作で適切に情報が読み上げられるかテストを行い、問題があれば修正します。

この記事では、前回作成した確認画面にこれらの対策を行い、スクリーンリーダーを使用しているユーザーでも扱えるように改善していきます。

アクセシビリティ対策の実装

まずは前回で作成したモーダルウィンドウの確認を行います。HTMLは以下でした。

<div id="modal" class="modal-wrap">
  <div class="modal-body">
    <h2 class="modal-ttl">入力内容の確認</h3>
    <div class="form-item">
      <p class="form-ttl">お問い合わせの種類</p>
      <p id="type-confirm"></p>
    </div>
    <div class="form-item">
      <p class="form-ttl">お名前</p>
      <p id="name-confirm"></p>
    </div>
    <div class="form-item">
      <p class="form-ttl">性別</p>
      <p id="gender-confirm"></p>
    </div>
    <div class="form-item">
      <p class="form-ttl">メールアドレス</p>
      <p id="email-confirm"></p>
    </div>
    <div class="form-item">
      <p class="form-ttl">学習中の言語</p>
      <p id="learning-confirm"></p>
    </div>
    <div class="form-item">
      <p class="form-ttl">お問い合わせ内容</p>
      <p id="content-confirm"></p>
    </div>
    <p class="modal-desc">上記の内容で送信します。<br>よろしければ「お問い合わせ内容を送信」を、<br>修正する場合は「修正する」を<br>クリックしてください。</p>
    <div class="btn-wrap">
      <button type="button" id="back" class="secondary-btn">修正する</button>
      <button type="submit" id="submit" class="primary-btn">お問い合わせ内容を送信</button>
    </div>
  </div>
</div>

このモーダルウィンドウの問題

このモーダルウィンドウのアクセシビリティに関する問題を挙げます。問題点は以下です。

  • モーダルを div 要素で実装しているため、スクリーンリーダーがモーダルであることを認識できない。
  • モーダルが表示されてもスクリーンリーダーは内容を読み上げない
  • Tabキーや矢印キーでモーダルの外の要素にアクセスできてしまう
  • Escキーでモーダルが閉じない

問題についての対策

これらの問題を解決するために、以下のような対策を行います。

  • スクリーンリーダーやブラウザにモーダルであることを伝える
  • フォーカスを管理してモーダル内の要素が読み上げられるようにする
  • モーダルに keydown イベントを追加する

それでは順番に対策を行っていきます。

スクリーンリーダーにモーダルであることを伝える

現状ではモーダルウィンドウを div 要素で実装しているため、スクリーンリーダーやブラウザはこの要素がモーダルであることを認識しません。

モーダルであることを伝えるには、「 role 属性を使用する」「 dialog 要素を使用する」の2パターンが考えられます。

dialog 要素でのモーダルウィンドウの実装はアクセシビリティの観点で最適な選択ですが、特殊な点が多いのでここでは扱いません。ここでは role 属性を使用していきます。

role属性を設定する

まずは div 要素でモーダルウィンドウを実装した場合の最低限の配慮として、スクリーンリーダーにこの要素がモーダルウィンドウであることを伝える必要があります。

HTMLにはWAI-ARIAという仕様によりアクセシビリティに関する属性が定義されています。 role 属性はWAI-ARIAで定義されている属性であり、これを使用することにより、スクリーンリーダーはその要素が div であっても意味のある要素として認識することができます。

role 属性でモーダルウィンドウであることを認識してもらうには、「 role=”dialog” 」と設定します。HTMLに以下を追加しましょう。

<div id="modal" class="modal-wrap" role="dialog" aria-hidden="true">
  …
</div>

ついでに、モーダルウィンドウが非表示のときはこの情報にアクセスして欲しくないので「 aria-hidden=”true” 」を設定しています。

aria-hidden 属性もWAI-ARIAで定義されているもので、値を true とすることでスクリーンリーダーはこの要素を存在しないものとして扱います。この属性は後にJavaScriptで制御します。

これでスクリーンリーダーにモーダルとして認識してもらうための作業は終わりです。続いて、フォーカスを管理してモーダル内の要素が読み上げられるように実装します。

フォーカスを管理してモーダル内の要素が読み上げられるようにする

現状ではモーダルウィンドウを開いてもスクリーンリーダーは内容を読み上げてくれません。これは、モーダルウィンドウが表示されたことをスクリーンリーダーが検知できていないことが原因と考えることができます。

この問題は、モーダルあるいはモーダル内の要素にフォーカスを移動させることで解決することができます。まずは下準備としてHTMLに変更を加えます。

<div id="modal" class="modal-wrap" role="dialog" aria-hidden="true">
  <div class="modal-body">
    <h2 class="modal-ttl" tabindex="0">入力内容の確認</h2>

    <div class="form-item" tabindex="0">
      <p id="type-ttl" class="form-ttl">お問い合わせの種類</p>
      <p id="type-confirm"></p>
    </div>
    <div class="form-item" tabindex="0">
      <p class="form-ttl">お名前</p>
      <p id="name-confirm"></p>
    </div>
    <div class="form-item" tabindex="0">
      <p class="form-ttl">性別</p>
      <p id="gender-confirm"></p>
    </div>
    <div class="form-item" tabindex="0">
      <p class="form-ttl">メールアドレス</p>
      <p id="email-confirm"></p>
    </div>
    <div class="form-item" tabindex="0">
      <p class="form-ttl">学習中の言語</p>
      <p id="learning-confirm"></p>
    </div>
    <div class="form-item" tabindex="0">
      <p class="form-ttl">お問い合わせ内容</p>
      <p id="content-confirm"></p>
    </div>
    …
  </div>
</div>

modal-ttl クラスを持った h2 要素と、各 form-item クラスを持った div 要素に「 tabindex=”0″ 」を追加しています。

<div class="form-item" tabindex="0">
  <p id="type-ttl" class="form-ttl">お問い合わせの種類</p>
  <p id="type-confirm"></p>
</div>

tabindex 属性はTabキーでフォーカス移動を行う際のフォーカスの順序を制御するための属性ですが、値に整数を指定することで div 要素のような通常でフォーカスを持たない要素に対してフォーカスを持たせることができます。

form-item tabindex 属性に同じ値を設定することで、通常の文書の流れに従った順序でフォーカスされるようになります。

これにより、確認画面の各 form-item がフォーカスを持てるようになり、フォーカスされた場合は form-item の子要素である form-ttl や入力内容が読み上げられるようになります。

下準備は以上です。続いて、モーダルウィンドウの aria-hidden 属性を動的に変更し、スクリーンリーダーがモーダルウィンドウにアクセスできるようにします。

aria-hidden 属性の変更を行うタイミングはモーダルウィンドウの表示・非表示のときに行うと良いでしょう。 modal.js 内にモーダルウィンドウの表示・非表示に関する関数を定義しているので、これらの関数に以下を追加します。

/**
 * モーダルを表示します。
 * @param {HTMLElement} modal - モーダル要素
 * @param {HTMLElement} submitBtn - 送信ボタン要素
 */
function openModal(modal, submitBtn) {
  modal.classList.add('show');
  document.body.style.overflow = 'hidden';
  submitBtn.setAttribute('form', 'form');

  // 以下を追加
  // アクセシビリティ対策
  if (modal.getAttribute('aria-hidden') === 'true') {
   modal.ariaHidden = 'false';
  }
  modal.querySelector('.modal-ttl').focus();
}

/**
 * モーダルを非表示にします。
 * @param {HTMLElement} modal - モーダル要素
 * @param {HTMLElement} submitBtn - 送信ボタン要素
 */
function closeModal(modal, submitBtn) {
  modal.classList.remove('show');
  document.body.style.overflow = 'visible';
  submitBtn.removeAttribute('form');

  // 以下を追加
  // アクセシビリティ対策
  if (modal.getAttribute('aria-hidden') === 'false') {
    modal.ariaHidden = 'true';
  }
  confirmBtn.focus();
}

getAttribute() メソッドを使用することで、引数に指定した要素の値を取得することができます。ここでは、 if 文の条件式で aria-hidden 属性の値を取得して値を反転させています。

// openModal関数の場合
if (modal.getAttribute('aria-hidden') === 'true') {
 modal.ariaHidden = 'false';
}

また、以下の記述によりモーダルウィンドウが表示された時にフォーカスが移動し、スクリーンリーダーはその要素の内容を読み上げてくれます。

// ここではモーダルウィンドウの見出しを指定しています。
modal.querySelector('.modal-ttl').focus();

これでスクリーンリーダーがモーダルウィンドウの内容を読み上げてくれるようになりましたが、Tabキーや矢印キーを使用するとモーダルウィンドウの外にある要素にアクセスできてしまうという問題があります。

この問題を解決するために、 openModal() 関数と closeModal() 関数に以下を追加します。

/**
 * モーダルを表示します。
 * @param {HTMLElement} modal - モーダル要素
 * @param {HTMLElement} submitBtn - 送信ボタン要素
 */
function openModal(modal, submitBtn) {
  …
  // アクセシビリティ対策
  if (modal.getAttribute('aria-hidden') === 'true') {
    modal.ariaHidden = 'false';
  }
  // 以下を追加
  document.querySelectorAll('body *:not(#modal, #modal *)').forEach(function(element) {
    element.setAttribute('aria-hidden', 'true');
  });
  modal.querySelector('.modal-ttl').focus();
}

function closeModal(modal, submitBtn) {
  …
  // アクセシビリティ対策
  if (modal.getAttribute('aria-hidden') === 'false') {
    modal.ariaHidden = 'true';
  }
  // 以下を追加
  document.querySelectorAll('body *:not(#modal, #modal *)').forEach(function(element) {
    element.removeAttribute('aria-hidden');
  });
  confirmBtn.focus();
}

querySelectorAll() メソッドで モーダルウィンドウ以外の全ての要素を取得し、 forEach() メソッドを使用して各要素の aria-hidden 属性を切り替えています。これにより、スクリーンリーダーはモーダルウィンドウが表示されているときに外の要素を読み上げなくなります。

// openModal()関数の例
  document.querySelectorAll('body *:not(#modal, #modal *)').forEach(function(element) {
    element.setAttribute('aria-hidden', 'true');
  });

しかし、読み上げられないというだけでTabキーによるフォーカス移動はできてしまうためこのままでは不十分です。 keydown イベントを実装してこの問題を解決していきます。

モーダルのkeydownイベントを追加する

ここではモーダルウィンドウに keydown イベントを追加し、TabキーやEscキーを押したときの動作を定義していきます。

イベントの登録は addEventListener() メソッドを使用します。 modal.js に以下を記述しましょう。

// モーダルウィンドウのキーボード操作
modal.addEventListener('keydown', function(event) {
  switch (event.key) {
    case 'Escape': {
      closeModal(modal, submitBtn);
      break;
    }
    case 'Tab': {
      if (event.shiftKey) {
        // フォーカスを上に移動する処理
      } else {
        // フォーカスを下に移動する処理
      }
      break;
    }
  } 
});

addEventListener() メソッドの第一引数に ‘keydown’ を指定した場合、 event.key とすることで押されたキーを取得できます。この値を使用して switch 文で条件分岐を行っています。

switch (event.key) {
 …
} 

case を追加することで他のキーを押した場合の動作を追加することもできますが、ここではEscキーとTabキーのみ制御します。

スクリーンリーダーを使用するユーザーは、様々なキーを利用してウェブサイトを閲覧することが考えられます。例えば、Hキーで次の見出しにジャンプするなど、特有の操作を行って情報にアクセスします。

そのため、キーボード操作を制御する際は必要以上にevent.preventDefault();などのイベントのリセットを行わないように気を配る必要があります。

keydownイベントなどでキーボード操作を制御する場合は入念なテストを行いましょう。

Escキーを押したときの処理

Escキーの処理はモーダルウィンドウを閉じるだけなので、 closeModal() 関数を実行するだけで良いでしょう。closeModal() 関数にはモーダルウィンドウを閉じるときに必要な処理が全て記述されています。

switch (event.key) {
  case 'Escape': {
    closeModal(modal, submitBtn);
    break;
  }
  case 'Tab': { … }
} 

Tabキーを押したときの処理

続いて、Tabキーの処理について解説します。

TabキーがShiftと同時に押されている場合はフォーカスを上に移動し、そうでなければフォーカスを下に移動するようにします。Shiftキーが押されているかどうかは event.shiftkey で取得できるので、この値を使用して条件分岐を行います。

case 'Tab': {
  if (event.shiftKey) {
    // フォーカスを上に移動する処理
  } else {
    // フォーカスを下に移動する処理
  }
  break;
}

フォーカスを移動する処理は、「フォーカス移動の方向が違う」ということ以外ほぼ同じ処理になるので、関数として定義します。また、フォーカスできる要素を関数の引数に渡すようにすれば再利用性が生まれるのでそのように実装します。以下のようなイメージです。

navigateFocus('フォーカス対象の要素の配列', '移動方向');

まずはモーダルウィンドウ内にあるフォーカスできる要素を配列として取得します。以下を追記しましょう。

case 'Tab': {
  const focusElements = Array.from(modal.querySelectorAll('[tabindex="0"], button'));
  if (event.shiftKey) {
    // フォーカスを上に移動する処理
  } else {
    // フォーカスを下に移動する処理
  }
  break;
}

ここでは focusElements という定数を定義し、 Array.from() メソッドを使用して querySelectorAll() で取得した NodeList 型のオブジェクトを配列型に変換しています。

配列型に変換することで、 NodeList では扱えない便利なメソッドを使用することができます。

関数の定義はまだしていませんが、関数の目的や指定する引数は決まっているので先に関数の呼び出しの記述をしておきます。

case 'Tab': {
  const focusElements = Array.from(modal.querySelectorAll('[tabindex="0"], button'));
  event.preventDefault();

  if (event.shiftKey) {
    navigateFocus(focusElements, 'up');
  } else {
    navigateFocus(focusElements, 'down');
  }
  break;
}

navigateFocus() 関数はこれから定義する関数です。第一引数に先ほど定義した配列を、第二引数には移動する方向を文字列で渡しています。

navigateFocus(focusElements, 'up');

また、Tabキーのデフォルトの動作とこの処理が同時に行われるとフォーカス移動が一度で2回行われてしまうため、1つ飛びでフォーカスが移動することになります。対策として、デフォルトの動作を行わないようにするためにTabキーが押されたタイミングで「 event.preventDefault(); 」を実行しています。

case 'Tab': {
  const focusElements = Array.from(modal.querySelectorAll('[tabindex="0"], button'));
  event.preventDefault();
  …
}

keydown イベントの内容は以上です。続いて、 navigateFocus() 関数を定義していきます。

フォーカスを移動するための関数を定義

まずは navigateFocus() 関数の宣言とjsdocsコメントです。

/**
 * フォーカス可能な要素間を指定された方向に移動する関数。
 * @param {Array<HTMLElement>} focusElements - フォーカス可能な要素の配列。
 * @param {string} direction - 移動の方向 ('down' または 'up')。
 */
function navigateFocus(focusElements, direction) {
  // 処理を記述
}

続いて、処理を記述していきます。この関数の実行時にフォーカスされている要素が focusElements に含まれていない場合は実行してほしくないので、まずはこれを判定します。

function navigateFocus(focusElements, direction) {
  // フォーカスされている要素がfocusElementsに含まれているかを判定
  if (focusElements.includes(document.activeElement)) {
    // 処理を記述
  }
}

includes() メソッドは配列で使用できるメソッドで、引数に指定した要素が配列に含まれていれば true を返します。 document.activeElement プロパティにはフォーカスされている要素が格納されており、この値を inclides() メソッドに渡して条件分岐を行っています。

これにより、フォーカスされている要素が focusElements に含まれていることが保証されます。

NodeList型のオブジェクトはincludes()メソッドを使うことができません。focusElementsを配列に変換しない場合は記述が少し複雑になっていたでしょう。

次に、フォーカスされている要素が focusElements 内でどの位置にあるかを特定し、その位置を基準に移動先の要素の位置を定義します。

function navigateFocus(focusElements, direction) {
  if (focusElements.includes(document.activeElement)) {
    const index = focusElements.indexOf(document.activeElement);
    const nextIndex = direction === 'down' ? index + 1 : index - 1;
  }
}

index 定数には、 indexOf() メソッドを使用してフォーカスされている要素のインデックス番号を格納しています。

const index = focusElements.indexOf(document.activeElement);

indexOf() も配列用のメソッドで、引数で受け取った要素が配列内に含まれていればその要素のインデックス番号を数値型で返し、含まれていなければ undefined を返します。

ここでは、前述のif (focusElements.includes(document.activeElement)) で undefined の可能性は排除されているので undefined 対策は必要ありません。

nextIndex 定数はフォーカス移動先のインデックス番号になります。この関数の引数で受け取った direction 引数の値を基に index 定数の値を加工して格納しています。

const nextIndex = direction === 'down' ? index + 1 : index - 1;

この関数の実行時に第二引数で受け取った値が ‘down’ という文字列であれば「 index – 1 」が、そうでなければ(’up’であれば)「 index + 1 」 nextIndex 定数に格納されます。

続いて、 nextIndex 定数の値を使用して次の要素をフォーカスする処理を実装します。ポイントとして、 focusElements は配列型なので、インデックス番号を使用して配列内の要素を特定することができます。

// focusElements内のnextIndex番目の要素がフォーカスされます。
focusElements[nextIndex].focus();

また、 nextIndex は数値型なので「<」「>」などの比較演算子を条件式で使用できます。

比較演算子を使用して条件分岐を行い、フォーカスされている要素が focusElements 内の最初の要素のときに direction 引数が ‘up’ だった場合と、最後の要素のときに direction 引数が ‘down’ だった場合、それ以外の場合で次にフォーカスする要素を切り分けます。

条件式は以下のようになります。

function navigateFocus(focusElements, direction) {
  if (focusElements.includes(document.activeElement)) {
    …
    if (nextIndex < 0) {
      // フォーカスされている要素が最初の要素のときの処理
    } else if (nextIndex > focusElements.length - 1) {
      // フォーカスされている要素が最後の要素のときの処理
    } else {
      // 上記以外の場合の処理
    }
  }
}

nextIndex 定数はフォーカスされている要素のインデックス番号を基に計算されていました。

フォーカスされている要素が最初の要素のときに direction 引数が ‘up’ だった場合、 nextIndex 定数の値は「 配列内の最初の要素のインデックス番号 – 1 」になります。配列内の最初の要素のインデックス番号は0です。よって、(nexIndex < 0)という条件が成立します。

また、フォーカスされている要素が最後の要素のときに direction 引数が ‘down’ だった場合、 nextIndex 定数の値は「 配列内の最後の要素のインデックス番号 + 1 」になります。配列の length プロパティには配列内の要素の数が格納されており、インデックス番号は0番目から割り振られるため、「 focusElements.length – 1 」で最後の要素のインデックス番号を取得することができます。よって、(nextIndex > focusElements.length – 1)の条件が成立します。

各条件での処理は対象の要素をフォーカスするだけなので簡単です。以下のように記述します。

if (nextIndex < 0) {
  focusElements[focusElements.length - 1].focus();
} else if (nextIndex > focusElements.length - 1) {
  focusElements[0].focus();
} else {
  focusElements[nextIndex].focus();
}

これで navigateFocus() 関数が完成しました。この関数の全体のコードは以下です。

/**
 * フォーカス可能な要素間を指定された方向に移動する関数。
 * @param {Array<HTMLElement>} focusElements - フォーカス可能な要素の配列。
 * @param {string} direction - 移動の方向 ('down' または 'up')。
 */
function navigateFocus(focusElements, direction) {
  if (focusElements.includes(document.activeElement)) {
    const index = focusElements.indexOf(document.activeElement);
    const nextIndex = direction === 'down' ? index + 1 : index - 1;

    if (nextIndex < 0) {
      focusElements[focusElements.length - 1].focus();
    } else if (nextIndex > focusElements.length - 1) {
      focusElements[0].focus();
    } else {
      focusElements[nextIndex].focus();
    }
  }
}

ここまでできたらブラウザで動作確認をすることができます。アクセシビリティに関する動作確認はスクリーンリーダーを使用することが望ましいですが、 keydown イベントのテストはスクリーンリーダーを使用しなくてもできるかと思います。

サンプルコードは以下です。

サンプルコード

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>お問い合わせ</title>
  <link rel="stylesheet" href="style.css">
  <script src="validation.js" defer></script>
  <script src="modal.js" defer></script>
</head>
<body>
  <header>
    <h1>LOGO</h1>
  </header>

  <main>
    <h2>お問い合わせ</h2>
    <form id="form" action="" method="GET">
      <div class="form-item">
        <label for="type" class="form-ttl">お問い合わせの種類</label>
        <div class="select-wrap">
          <select id="type" name="type">
            <option value="learn" selected>ウェブ制作の学び方について</option>
            <option value="article">記事の内容について</option>
            <option value="site">このサイトの使い方について</option>
            <option value="admin">このサイトの管理者について</option>
          </select>
        </div>
      </div>

      <div class="form-item">
        <label for="name" class="form-ttl">お名前<span class="required">必須</span></label>
        <p class="example-txt">例:山田 太郎</p>
        <p class="err-msg">お名前を入力してください</p>
        <input type="text" id="name" name="name" required>
      </div>

      <div class="form-item">
        <fieldset>
          <legend class="form-ttl">性別</legend>
          <label for="male"><input type="radio" id="male" name="gender" value="male">男性</label>
          <label for="female"><input type="radio" id="female" name="gender" value="female">女性</label>
        </fieldset>
      </div>

      <div class="form-item">
        <label for="email" class="form-ttl">メールアドレス<span class="required">必須</span></label>
        <p class="example-txt">例:hogehoge@example.com</p>
        <p class="err-msg">メールアドレスを入力してください</p>
        <input type="mail" id="email" name="email" required>
      </div>

      <div class="form-item">
        <fieldset>
          <legend class="form-ttl">学習中の言語</legend>
          <label for="html"><input type="checkbox" id="html" name="learning" value="html" checked>HTML</label>
          <label for="css"><input type="checkbox" id="css" name="learning" value="css">CSS</label>
          <label for="javascript"><input type="checkbox" id="javascript" name="learning" value="javascript">JavaScript</label>
          <label for="php"><input type="checkbox" id="php" name="learning" value="php">PHP</label>
        </fieldset>
      </div>

      <div class="form-item">
        <label for="content" class="form-ttl">お問い合わせ内容<span class="required">必須</span></label>
        <p class="err-msg">お問い合わせ内容を入力してください</p>
        <textarea id="content" name="content" rows="20" required></textarea>
      </div>

      <div class="policy-check-wrap">
        <label for="policy-check">
          <p class="err-msg">個人情報保護方針に同意してください</p>
          <input
            type="checkbox"
            id="policy-check"
            name="policy-check"
            required>個人情報保護方針に同意します。
        </label>
      </div>

      <div class="btn-wrap">
        <button type="submit" id="confirm" class="primary-btn">入力内容を確認</button>
      </div>
    </form>
  </main>

  <div id="modal" class="modal-wrap" role="dialog" aria-hidden="true">
    <div class="modal-body">
      <h2 class="modal-ttl" tabindex="0">入力内容の確認</h2>

      <div class="form-item" tabindex="0">
        <p id="type-ttl" class="form-ttl">お問い合わせの種類</p>
        <p id="type-confirm"></p>
      </div>
      <div class="form-item" tabindex="0">
        <p class="form-ttl">お名前</p>
        <p id="name-confirm"></p>
      </div>
      <div class="form-item" tabindex="0">
        <p class="form-ttl">性別</p>
        <p id="gender-confirm"></p>
      </div>
      <div class="form-item" tabindex="0">
        <p class="form-ttl">メールアドレス</p>
        <p id="email-confirm"></p>
      </div>
      <div class="form-item" tabindex="0">
        <p class="form-ttl">学習中の言語</p>
        <p id="learning-confirm"></p>
      </div>
      <div class="form-item" tabindex="0">
        <p class="form-ttl">お問い合わせ内容</p>
        <p id="content-confirm"></p>
      </div>
      <p class="modal-desc">上記の内容で送信します。<br>よろしければ「お問い合わせ内容を送信」を、<br>修正する場合は「修正する」を<br>クリックしてください。</p>
      <div class="btn-wrap">
        <button type="button" id="back" class="secondary-btn">修正する</button>
        <button type="submit" id="submit" class="primary-btn">お問い合わせ内容を送信</button>
      </div>
    </div>
  </div>
  <footer>&copy;copyright.</footer>
</body>
</html>

modal.js

/**
 * 確認画面モーダルを表示します。
 */
function showConfirm() {
  /**
   * フォームの確認データ
   * @type {Object.<string, { insert: HTMLElement, value: string }>}
   */
  const confirmData = {
    type: {
      insert: document.getElementById('type-confirm'),
      value: document.getElementById('type').options[document.getElementById('type').selectedIndex].text,
    },
    name: {
      insert: document.getElementById('name-confirm'),
      value: document.getElementById('name').value,
    },
    gender: {
      insert: document.getElementById('gender-confirm'),
      value: getLabels(document.querySelectorAll('input[name=gender]:checked')),
    },
    email: {
      insert: document.getElementById('email-confirm'),
      value: document.getElementById('email').value,
    },
    learning: {
      insert: document.getElementById('learning-confirm'),
      value: getLabels(document.querySelectorAll('input[name=learning]:checked')),
    },
    content: {
      insert: document.getElementById('content-confirm'),
      value: document.getElementById('content').value,
    }
  }

  insertValue(confirmData);
  openModal(modal, submitBtn);

  if ('hasClickEvent' in backBtn === false) {
    backBtn.hasClickEvent = true;
    backBtn.addEventListener('click', function () {
      closeModal(modal, submitBtn);
    });
  }
}

/**
 * モーダルを表示します。
 * @param {HTMLElement} modal - モーダル要素
 * @param {HTMLElement} submitBtn - 送信ボタン要素
 */
function openModal(modal, submitBtn) {
  modal.classList.add('show');
  document.body.style.overflow = 'hidden';
  submitBtn.setAttribute('form', 'form');

  // アクセシビリティ対策
  if (modal.getAttribute('aria-hidden') === 'true') {
    modal.ariaHidden = 'false';
  }
  document.querySelectorAll('body *:not(#modal, #modal *)').forEach(function (element) {
    element.setAttribute('aria-hidden', 'true');
  });
  modal.querySelector('.modal-ttl').focus();
}

/**
* モーダルを非表示にします。
* @param {HTMLElement} modal - モーダル要素
* @param {HTMLElement} submitBtn - 送信ボタン要素
*/
function closeModal(modal, submitBtn) {
  modal.classList.remove('show');
  document.body.style.overflow = 'visible';
  submitBtn.removeAttribute('form');

  // アクセシビリティ対策
  if (modal.getAttribute('aria-hidden') === 'false') {
    modal.ariaHidden = 'true';
  }
  document.querySelectorAll('body *:not(#modal, #modal *)').forEach(function (element) {
    element.removeAttribute('aria-hidden');
  });
  confirmBtn.focus();
}

/**
 * valueをinsertに指定した要素のinnerHTMLに挿入します。
 * @param {Object.<string, { insert: HTMLElement, value: string }>} data - データオブジェクト
 */
function insertValue(data) {
  for (const key in data) {
    if (data[key].value) {
      data[key].insert.innerHTML = data[key].value;
    }
  }
}

/**
 * ラベル要素のテキストを取得し、HTML要素として返します。
 * @param {NodeListOf<HTMLInputElement>} targets - 対象の入力要素ノードリスト
 * @returns {string} HTML形式のテキスト
 */
function getLabels(targets) {
  let result = '';

  if (targets.length === 0) {
    result = '選択されていません。';
  } else {
    targets.forEach(function (target) {
      const label = document.querySelector('label[for=' + target.id + ']');
      const spanElement = document.createElement('span');
      spanElement.innerHTML = label.textContent;
      result += spanElement.outerHTML;
    });
  }
  return result;
};

/**
 * フォーカス可能な要素間を指定された方向に移動する関数。
 * @param {Array<HTMLElement>} focusElements - フォーカス可能な要素の配列。
 * @param {string} direction - 移動の方向 ('down' または 'up')。
 */
function navigateFocus(focusElements, direction) {
  if (focusElements.includes(document.activeElement)) {
    const index = focusElements.indexOf(document.activeElement);
    const nextIndex = direction === 'down' ? index + 1 : index - 1;

    if (nextIndex < 0) {
      focusElements[focusElements.length - 1].focus();
    } else if (nextIndex > focusElements.length - 1) {
      focusElements[0].focus();
    } else {
      focusElements[nextIndex].focus();
    }
  }
}

// モーダルウィンドウのキーボード操作
modal.addEventListener('keydown', function (event) {

  switch (event.key) {
    case 'Escape': {
      closeModal(modal, submitBtn);
      break;
    }
    case 'Tab': {
      const focusElements = Array.from(modal.querySelectorAll('[tabindex="0"], button'));
      event.preventDefault();
      if (event.shiftKey) {
        navigateFocus(focusElements, 'up');
      } else {
        navigateFocus(focusElements, 'down');
      }
      break;
    }
  }
});

最後に

【確認画面の実装編】で制作したモーダルウィンドウの確認画面に対し、基本的なアクセシビリティ対策を行いました。

普段何気なく記述しているHTMLがスクリーンリーダーでどのように読み上げられるかをイメージできると、適切なタグの選択に役立つことと思います。

アクセシビリティに関する課題は奥が深く、より本格的なアクセシビリティ対策に関してはWeb Content Accessibility Guidelines(WCAG)に従うことが望ましいです。興味があれば調べてみると良いでしょう。

お疲れさまでした

この記事はウェブ制作初学者の方を対象にしているため最低限の対策しか行っていませんが、もとより、アクセシビリティ対策は初学者の方向けではないかもしれません。

ウェブ制作のスキルは一朝一夕で身につくものではないので、興味の強い分野を中心に、トライ&エラーを繰り返しながら学んでいくと継続しやすいと思います。

この記事をもって【お問い合わせフォームを作ってみよう】チュートリアルは終了です。このチュートリアルの完成コードは以下のページにまとめてあります。必要に応じて学習や制作の参考にして頂けると幸いです。

タイトルとURLをコピーしました