💬

アクセシブルなモーダルダイアログの実装について考える

2022/05/11に公開

今更ですがAccessible Rich Internet Applications (WAI-ARIA) 1.2ARIA Authoring Practices Guide (APG)をもとに、モーダルダイアログの実装について考えてみるという内容です。

また上記に加えMicromodal.jsをはじめとした、いくつかのライブラリも参考にしています。

※以下本記事においてダイアログと表記があればモーダルダイアログのこと。

dailog要素の検討(本記事では扱わない)

現時点で最新のモダンブラウザを対象とするのであれば<dialog>: ダイアログ要素を検討してもいいと思います。

https://caniuse.com/dialog

ただし本記事では、daiog要素をサポートしていないブラウザも対象としたいため、daiog要素については扱いません(余裕があれば他の機会でまた書きたい)。

ダイアログの要件について考える

アクセシブルなダイアログの要件について考えてみます。

https://www.w3.org/WAI/ARIA/apg/patterns/dialogmodal/

最初にAbout This Pattern の項目を読んでみましょう。
次のような記述があります。

Dialog(Modal)

A dialog is a window overlaid on either the primary window or another dialog window. Windows under a modal dialog are inert. That is, users cannot interact with content outside an active dialog window. Inert content outside an active dialog is typically visually obscured or dimmed so it is difficult to discern, and in some implementations, attempts to interact with the inert content cause the dialog to close.

Like non-modal dialogs, modal dialogs contain their tab sequence. That is, Tab and Shift + Tab do not move focus outside the dialog. However, unlike most non-modal dialogs, modal dialogs do not provide means for moving keyboard focus outside the dialog window without closing the dialog.

まとめると以下のようになるかと思います。

  • ダイアログを開いている間、アクティブなダイアログウィンドウの外側(不活性なコンテンツ)は、視認性を低くする。
  • ダイアログを開いている間、ユーザーは不活性なコンテンツを操作できない。一部の実装では不活性なコンテンツを操作しようとするとモーダルを閉じる
  • ダイアログを開いている間、不活性なコンテンツにフォーカスを移動させない。TabもしくはShift + Tabでフォーカスをダイアログの外側に移動させない。

続いて Keyboard Interaction の項目には次のような記述があります。

Keyboard Interaction

In the following description, the term tabbable element refers to any element with a tabindex value of zero or greater. Note that values greater than 0 are strongly discouraged.

  • When a dialog opens, focus moves to an element inside the dialog. See notes below regarding initial focus placement.
  • Tab:
    • Moves focus to the next tabbable element inside the dialog.
    • If focus is on the last tabbable element inside the dialog, moves focus to the first tabbable element inside the dialog.
  • Shift + Tab:
  • Moves focus to the previous tabbable element inside the dialog.
    • If focus is on the first tabbable element inside the dialog, moves focus to the last tabbable element inside the dialog.
  • Escape: Closes the dialog.

Note

  1. When a dialog opens, focus moves to an element contained in the dialog. Generally, focus is initially set on the first focusable element. However, the most appropriate focus placement will depend on the nature and size of the content. Examples include:
  • If the dialog content includes semantic structures, such as lists, tables, or multiple paragraphs, that need to be perceived in order to easily understand the content, i.e., if the content would be difficult to understand when announced as a single unbroken string, then it is advisable to add tabindex="-1" to a static element at the start of the content and initially focus that element. This makes it easier for assistive technology users to read the content by navigating the semantic structures. Additionally, it is advisable to omit applying aria-describedby to the dialog container in this scenario.
  • If content is large enough that focusing the first interactive element could cause the beginning of content to scroll out of view, it is advisable to add tabindex="-1" to a static element at the top of the dialog, such as the dialog title or first paragraph, and initially focus that element.
  • If a dialog contains the final step in a process that is not easily reversible, such as deleting data or completing a financial transaction, it may be advisable to set focus on the least destructive action, especially if undoing the action is difficult or impossible. The Alert Dialog Pattern is often employed in such circumstances.
  • If a dialog is limited to interactions that either provide additional information or continue processing, it may be advisable to set focus to the element that is likely to be most frequently used, such as an OK or Continue button.
  1. When a dialog closes, focus returns to the element that invoked the dialog unless either:
  • The invoking element no longer exists. Then, focus is set on another element that provides logical work flow.

  • The work flow design includes the following conditions that can occasionally make focusing a different element a more logical choice:

    1. It is very unlikely users need to immediately re-invoke the dialog.
    2. The task completed in the dialog is directly related to a subsequent step in the work flow.

    For example, a grid has an associated toolbar with a button for adding rows. the Add Rows button opens a dialog that prompts for the number of rows. After the dialog closes, focus is placed in the first cell of the first new row.

  1. It is strongly recommended that the tab sequence of all dialogs include a visible element with role button that closes the dialog, such as a close icon or cancel button.

まとめると以下のようになるかと思います。

  • ダイアログを開いた時に、フォーカスがダイアログの中の要素に移る。初期フォーカス位置については割愛するが、通常は(特定の条件がなければ)最初のtabbableな要素
  • Tabでフォーカスが次のtabbableな要素に移る。フォーカスがダイアログ内で最後のtabbableな要素の場合は、最初のtabbableな要素に移る。
  • Shift + Tabでフォーカスが前のtabbableな要素に移る。フォーカスがダイアログ内で最初のtabbableな要素の場合は、最後のtabbableな要素に移る。
  • Escapeでダイアログを閉じる。
  • ダイアログを閉じた時に、通常は(特定の条件がなければ)ダイアログを呼び出した要素にフォーカスを戻す。
  • ダイアログの中に、ダイアログを閉じるためのbuttonの役割を持った要素を含めることが推奨される。

tabbableな要素とはtabindexが0以上に該当する要素。

最後に WAI-ARIA Roles, States, and Properties の項目には次のような記述があります。

WAI-ARIA Roles, States, and Properties
  • The element that serves as the dialog container has a role of dialog.
  • All elements required to operate the dialog are descendants of the element that has role dialog.
  • The dialog container element has aria-modal set to true.
  • The dialog has either:
    • A value set for the aria-labelledby property that refers to a visible dialog title.
    • A label specified by aria-label.
  • Optionally, the aria-describedby property is set on the element with the dialog role to indicate which element or elements in the dialog > contain content that describes the primary purpose or message of the dialog. Specifying descriptive elements enables screen readers to announce the description along with the dialog title and initially focused element when the dialog opens, which is typically helpful only when the descriptive content is simple and can easily be understood without structural information. It is advisable to omit specifying aria-describedby if the dialog content includes semantic structures, such as lists, tables, or multiple paragraphs, that need to be perceived in order to easily understand the content, i.e., if the content would be difficult to understand when announced as a single unbroken string.

NOTE

  • Because marking a dialog modal by setting aria-modal to true can prevent users of some assistive technologies from perceiving content outside the dialog, users of those technologies will experience severe negative ramifications if a dialog is marked modal but does not behave as a modal for other users. So, mark a dialog modal only when both:
    1. Application code prevents all users from interacting in any way with content outside of it.
    2. Visual styling obscures the content outside of it.
  • The aria-modal property introduced by ARIA 1.1 replaces aria-hidden for informing assistive technologies that content outside a dialog is inert. However, in legacy dialog implementations where aria-hidden is used to make content outside a dialog inert for assistive technology users, it is important that:
    1. aria-hidden is set to true on each element containing a portion of the inert layer.
    2. The dialog element is not a descendant of any element that has aria-hidden set to true.

こちらもまとめると以下のようになるかと思います。

  • ダイログ要素にはrole="dialog"aria-modal="true"を付与する。
  • ダイログに表示されたタイトルがある時は、それを参照するaria-labelledbyを使用する。表示されたタイトルがない時は、aria-labelを使用する。
  • ダイアログの主な目的やメッセージを説明する要素がある時は、aria-describebyで参照する(なくても良い)。
  • aria-modalaria-hiddenに代替する要素である。これはすなわちaria-modalを使用することで、ダイアログを開いている間も不活性なコンテンツにaria-hiddenをつける必要がないことを意味する(ただし調べた限りだとaria-modalをサポートしていないブラウザもあるようなので、この記事ではaria-modalaria-hiddenを併用して実装する)。

要件まとめ

ここまでの内容を振り返りながら、本記事では以下の要件のダイアログを実装しようと思います。

  • ダイアログを開いた時に、フォーカスがダイアログの中の最初のtabbableな要素に移る。
  • ダイアログを開いている間、フォーカストラップ(ここまでに出てきたフォーカスの挙動諸々)を実装する。
  • ダイアログを開いている間、不活性なコンテンツの視認性を低くし、ユーザーが操作できないようにする。
  • ダイアログの中に、ダイアログを閉じるためのbuttonを含める。
  • モーダルの背景をクリックでダイアログを閉じる。
  • Escapeでダイアログを閉じる。
  • ダイアログを閉じた時に、ダイアログを呼び出した要素にフォーカスを戻す。
  • rolearia-modalaria-labelledbyaria-describebyaria-hiddenなどのWAI-ARIA属性を実装する

実装

ベースコーディング

まずはベースとして以下のようにコーディングします。

上記はいたってシンプルなモーダルダイアログになります。

*[data-open-trigger="dialog"]をクリックすると、.dialogから.__hiddenが外れて、ダイアログが表示されます。
*[data-close-trigger="dialog"]をクリックした時はその反対で、.dialog.__hiddenを付与することでダイアログが非表示になります。

上記をコーディングのはじめとして、アクセシブルなダイアログの実装に必要なものを加えていきます。

現状の対応項目
  • ダイアログを開いた時に、フォーカスがダイアログの中の最初のtabbableな要素に移る。
  • ダイアログを開いている間、フォーカストラップ(ここまでに出てきたフォーカスの挙動諸々)を実装する。
  • ダイアログを開いている間、不活性なコンテンツの視認性を低くし、ユーザーが操作できないようにする。
  • ダイアログの中に、ダイアログを閉じるためのbuttonを含める。
  • モーダルの背景をクリックでダイアログを閉じる。
  • Escapeでダイアログを閉じる。
  • ダイアログを閉じた時に、ダイアログを呼び出した要素にフォーカスを戻す。
  • rolearia-modalaria-labelledbyaria-describebyaria-hiddenなどのWAI-ARIA属性を実装する

フォーカス制御

やることが多くて少し大変ですが、頑張って実装しましょう。
フォーカス制御については、いろいろありますが大きく分けてやることは3つです。

  • ダイアログを開いた時、フォーカスをダイアログに当てる
  • ダイアログを開いている間、フォーカスの制御(フォーカストラップ)をする
  • ダイアログを閉じた時、フォーカスを元の位置に戻す

ダイアログ開閉時のフォーカスの挙動を制御する

まずは開閉時のフォーカス制御について実装します。

  • ダイアログが開いた時に、ダイアログ内の最初のtabbableな要素にフォーカスを移す。
  • ダイアログが閉じた時に、ダイアログを呼び出した要素にフォーカスを戻す。

CSSでtransitionの対象とするプロパティについては気をつける必要があります。
visibility: hiddenの要素にはelement.focus()でフォーカスをあてることができません。
ゆえにダイログを開く時はtransitionの対象をopacityのみにしています。

css
.dialog {
  opacity: 1;
  visibility: visible;
  /* opacityのみ */
  transition: opacity 0.2s ease-out;
}
.dialog.__hidden {
  opacity: 0;
  visibility: hidden;
  /* opacityとvisibility */
  transition: all 0.2s ease-out;
}

tabbableな要素Micromodal.jsのソースコードから拝借させていただきました。

javascript
// tabbableな要素は Micromodal.js を参考に実装
// https://github.com/ghosh/Micromodal/blob/master/lib/src/index.js
const FOCUSABLE_ELEMENTS = [
  "a[href]",
  "area[href]",
  'input:not([disabled]):not([type="hidden"]):not([aria-hidden])',
  "select:not([disabled]):not([aria-hidden])",
  "textarea:not([disabled]):not([aria-hidden])",
  "button:not([disabled]):not([aria-hidden])",
  "iframe",
  "object",
  "embed",
  "[contenteditable]",
  '[tabindex]:not([tabindex^="-"])',
];
// ダイアログの中でtabbableな要素を取得
const focusableElements = [
  ...dialog.querySelectorAll(FOCUSABLE_ELEMENTS.join(","))
];
// ダイアログを開く時に、直前にフォーカスが当たっていた要素を格納する
let beforeFocusedElement = null;

// ダイアログを開く
const handleDialogOpen = () => {
  if (!dialog.classList.contains("__hidden")) return;

  dialog.classList.remove("__hidden");
  // ダイアログを開く直前のフォーカスの取得
  beforeFocusedElement = document.activeElement;
  // ダイアログ内の最初のtabbableな要素にフォーカスをあてる
  focusableElements[0].focus();
};

// ダイアログを閉じる
const handleDialogClose = () => {
  if (dialog.classList.contains("__hidden")) return;

  dialog.classList.add("__hidden");
  // ダイアログを開く時に、直前にフォーカスが当たっていた要素にフォーカスを戻す
  beforeFocusedElement.focus();
  beforeFocusedElement = null;
};

全体のコードは以下になります。

フォーカストラップの実装

次にモーダルが開いている間のフォーカストラップの実装を行います。

https://web.dev/using-tabindex/

上記の記事を参考に、フォーカスをダイアログから出さないようにしています。

  • Tabを押下時に、ダイアログ内で最後のtabbableな要素にフォーカスがある場合は、最初のtabbableな要素にフォーカスを移す。
  • Shift + Tabを押下時に、ダイアログ内で最初のtabbableな要素にフォーカスがある場合は、最後のtabbableな要素にフォーカスを移す。
javascript
const handleKeydownDiaogContainer = (e) => {
  const firstFocusableElement = focusableElements[0];
  const lastFocusableElement = focusableElements[focusableElements.length - 1];

  if (e.code === "Tab") {
    if (e.shiftKey) {
      // Shift + Tab
      if (document.activeElement === firstFocusableElement) {
        e.preventDefault();
        // ダイアログ内で最初のtabableの要素の時、最後のtabableの要素にフォーカスを移す
        lastFocusableElement.focus();
      }
    } else {
      // Tab
      if (document.activeElement === lastFocusableElement) {
        e.preventDefault();
        // ダイアログ内で最後のtabableの要素の時、最初のtabableの要素にフォーカスを移す
        firstFocusableElement.focus();
      }
    }
  }
};

dialog.addEventListener("keydown", handleKeydownDiaogContainer);

全体のコードは以下になります。
※以下のコードではフォーカストラップの挙動をわかりやすくするため、「ダイアログを閉じる」ボタンを3つ用意しています。

現状の対応項目
  • ダイアログを開いた時に、フォーカスがダイアログの中の最初のtabbableな要素に移る。
  • ダイアログを開いている間、フォーカストラップ(ここまでに出てきたフォーカスの挙動諸々)を実装する。
  • ダイアログを開いている間、不活性なコンテンツの視認性を低くし、ユーザーが操作できないようにする。
  • ダイアログの中に、ダイアログを閉じるためのbuttonを含める。
  • モーダルの背景をクリックでダイアログを閉じる。
  • Escapeでダイアログを閉じる。
  • ダイアログを閉じた時に、ダイアログを呼び出した要素にフォーカスを戻す。
  • rolearia-modalaria-labelledbyaria-describebyaria-hiddenなどのWAI-ARIA属性を実装する

不活性なコンテンツをユーザーが操作できないようにする

ここでやることは主に2つです。

  • ダイアログを開いている間、スクロールを止める
  • 不活性なコンテンツを選択できないようにする

選択の無効化にはCSSのuser-select:noneを使います。

css
/* スクロール制御のスタイル */
/* --scroll-yはJSで値をセットする */
:root {
  --scroll-y: 0;
}
/* ... */
.fixed {
  position: fixed;
  top: var(--scroll-y);
  left: 0;
  width: 100%;
  height: 100%;
  overflow: hidden;
}
/* 選択を無効化 */
.user-select-none {
  -webkit-user-select: none;
  user-select: none;
}
javascript
const handleDialogOpen = () => {
  //...
  // スクロールと選択操作の処理を追加
  bgScrollBehavior("fix");
  noSelectContents(true);
};
const handleDialogClose = () => {
  //...
  // スクロールと選択操作の処理を追加
  bgScrollBehavior("scroll");
  noSelectContents(false);
};

const bgScrollBehavior = (state) => {
  const isFixed = state === "fix";

  if (isFixed) {
    // スクロールを止める処理
    // .fixedのスタイルを用意
    const scrollY = document.documentElement.scrollTop;
    document.body.classList.add("fixed");
    document.documentElement.style.setProperty(
      "--scroll-y",
      `${scrollY * -1}px`
    );
  } else {
    // スクロール停止を解除する処理
    const scrollY = parseInt(
      document.documentElement.style.getPropertyValue("--scroll-y") || "0"
    );
    document.body.classList.remove("fixed");
    window.scrollTo(0, scrollY * -1);
  }
};

const noSelectContents = (bool) => {
  // .user-select-noneのスタイルを用意
  if (bool) {
    main.classList.add("user-select-none");
  } else {
    main.classList.remove("user-select-none");
  }
};

全体のコードはこちらです。
※スクロールの挙動を確認しやすいように、コンテンツの高さをmin-height: 200vhとしています。

現状の対応項目
  • ダイアログを開いた時に、フォーカスがダイアログの中の最初のtabbableな要素に移る。
  • ダイアログを開いている間、フォーカストラップ(ここまでに出てきたフォーカスの挙動諸々)を実装する。
  • ダイアログを開いている間、不活性なコンテンツの視認性を低くし、ユーザーが操作できないようにする。
  • ダイアログの中に、ダイアログを閉じるためのbuttonを含める。
  • モーダルの背景をクリックでダイアログを閉じる。
  • Escapeでダイアログを閉じる。
  • ダイアログを閉じた時に、ダイアログを呼び出した要素にフォーカスを戻す。
  • rolearia-modalaria-labelledbyaria-describebyaria-hiddenなどのWAI-ARIA属性を実装する

Escapeでダイアログを閉じる

ここはタイトルの通りですね。実装自体はシンプルなので、とくに解説も必要ないと思います。

javascript
const handleKeydownDiaogContainer = (e) => {
  //...
  // Escapeの押下でダイアログを閉じる
  if (e.code === "Escape") {
    handleDialogClose();
  }
};
現状の対応項目
  • ダイアログを開いた時に、フォーカスがダイアログの中の最初のtabbableな要素に移る。
  • ダイアログを開いている間、フォーカストラップ(ここまでに出てきたフォーカスの挙動諸々)を実装する。
  • ダイアログを開いている間、不活性なコンテンツの視認性を低くし、ユーザーが操作できないようにする。
  • ダイアログの中に、ダイアログを閉じるためのbuttonを含める。
  • モーダルの背景をクリックでダイアログを閉じる。
  • Escapeでダイアログを閉じる。
  • ダイアログを閉じた時に、ダイアログを呼び出した要素にフォーカスを戻す。
  • rolearia-modalaria-labelledbyaria-describebyaria-hiddenなどのWAI-ARIA属性を実装する

WAI-ARIA属性を実装する

WAI-ARIAの属性を付与します。
アクセシビリティの実装において、多くの方にとって、もっとも馴染みのない分野がここになるかもしれません。

  • ダイログ要素にはrole="dialog"aria-modal="true"を付与する。
  • ダイログのタイトルに対してaria-labelledbyで参照する。
  • ダイアログの説明要素に対してaria-describebyで参照する。
  • ダイアログを開いている間、不活性なコンテンツのルート要素にaria-hiddenを付与する。
html
<!-- /... -->
<div id="dialog" class="dialog __hidden">
  <div class="dialog__bglayer" dialog-close-trigger="dialog"></div>
  <!-- WAI-ARIA属性の付与 -->
  <div role="dialog" aria-modal="true" aria-labelledby="dialog-title" aria-describeby="dialog-desc" class="dialog__container">
    <h2 id="dialog-title" class="dialog__title">ダイアログです</h2>
    <p id="dialog-desc" class="dialog__desc">
      これはダイアログのサンプルです。
    </p>
    <div class="dialog__action">
      <button class="dialog-close-trigger" dialog-close-trigger="dialog">
        ダイアログを閉じる
      </button>
    </div>
  </div>
</div>
javascript
const main = document.getElementById("main");
const handleDialogOpen = () => {
  //...
  // 非活性なコンテンツのルート要素にaria-hidden属性を与する
  // モーダルを開く時はaria-hiddenをtrueに
  main.setAttribute("aria-hidden", "true");
};
const handleDialogClose = () => {
  //...
  // モーダルを閉じる時はaria-hiddenをfalseに
  main.setAttribute("aria-hidden", "false");
};
現状の対応項目
  • ダイアログを開いた時に、フォーカスがダイアログの中の最初のtabbableな要素に移る。
  • ダイアログを開いている間、フォーカストラップ(ここまでに出てきたフォーカスの挙動諸々)を実装する。
  • ダイアログを開いている間、不活性なコンテンツの視認性を低くし、ユーザーが操作できないようにする。
  • ダイアログの中に、ダイアログを閉じるためのbuttonを含める。
  • モーダルの背景をクリックでダイアログを閉じる。
  • Escapeでダイアログを閉じる。
  • ダイアログを閉じた時に、ダイアログを呼び出した要素にフォーカスを戻す。
  • rolearia-modalaria-labelledbyaria-describebyaria-hiddenなどのWAI-ARIA属性を実装する

以上で実装完了です!ここまでお疲れ様でした!

おまけ:コードをまとめる

おまけです。コードの解説はしませんがコードをまとていめます(読み飛ばしても、全然問題ないです)。
この辺りはリファクタリングに関しては、各々のお好みでいいと思います。

下記の実装例では以下のような感じで使えるようにまとめています。
もし興味がありましたら、参考程度にコードを読んでいただけると幸いです。

javascript
const dialog = dialogControl();
dialog.init({
  dialogId: "dialog",
  openTrigger: `*[data-open-trigger="dialog"]`,
  closeTrigger: `*[data-close-trigger="dialog"]`,
  mainContents: ".main",
});

おわりに

今更ではありますが、改めてアクセシブルなダイアログの実装を考えるという話でした。
正直なところ、WAI-ARIA の仕様や WAI-ARIA Authoring Practices を読むのは、それなりの根気が必要な上、敷居が高く感じます。
とはいえ、アクセシビリティは考慮しなくてはいいものでは決してないので、どこかで腰を据えて勉強することが必要ですね。

参考にさせていただいた仕様や記事、実装など

https://www.w3.org/TR/wai-aria-1.2/
https://www.w3.org/WAI/ARIA/apg/
https://www.w3.org/WAI/ARIA/apg/example-index/dialog-modal/dialog.html
https://web.dev/using-tabindex/
https://micromodal.vercel.app/
https://mui.com/material-ui/react-modal/
https://getbootstrap.com/docs/5.1/components/modal/
https://zenn.dev/dqn/articles/36045bb89d5d69
https://accessible-usable.net/2015/07/entry_150706.html

Discussion