⌨️

実装しながら理解するモーダルのアクセシビリティ with React

2022/01/23に公開3

はじめに

この記事では以下のアクセシビリティ要件を満たすモーダルを実装します。

  • モーダル要素に role 属性、aria-modal 属性、aria-labelledby 属性、aria-describedby 属性が付与されている
  • モーダルを開くと、モーダル内の最初の focusable な要素に自動でフォーカスされる
  • モーダルが開いている間、モーダル以外の要素に aria-hidden 属性が付与される
  • モーダルが開いている間、モーダル以外の要素のスクロールが無効化される
  • モーダルが開いている間、モーダル内でフォーカスがトラップされる
  • Esc キーを押下すると、モーダルが閉じる
  • モーダルの外側をクリックすると、モーダルが閉じる
  • モーダルを閉じると、モーダルが開く前にフォーカスされていた要素にフォーカスが戻る

ベースとなるモーダル

以下はアクセシビリティが何も考慮されていないモーダルのコンポーネントです。このコンポーネントをベースに機能を追加していきます。

import styles from "./Modal.module.css";

type Props = {
  isOpen: boolean;
};

const Modal: React.FC<Props> = ({ isOpen, children }) => {
  if (!isOpen) {
    return null;
  }

  return createPortal(
    <div className={styles.overlay}>
      <div className={styles.content}>{children}</div>
    </div>,
    document.body,
  );
};
CSS
Modal.module.css
.overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.5);
  padding: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.content {
  background: #fff;
  width: 100%;
  max-width: 800px;
  padding: 20px;
  border-radius: 12px;
}

WAI-ARIA 属性の付与

この節では以下を実装します。

  • モーダル要素に role 属性、aria-modal 属性、aria-labelledby 属性、aria-describedby 属性が付与されている
  • モーダルが開いている間、モーダル以外の要素に aria-hidden 属性が付与される

まずは要素がモーダルであることを示すために role 属性と aria-modal 属性を付与します。

  <div className={styles.overlay}>
-   <div className={styles.content}>
+   <div role="dialog" aria-modal className={styles.content}>
      {children}
    </div>
  </div>,

dialog (role):
https://www.w3.org/TR/wai-aria-1.1/#dialog

aria-modal (property):
https://www.w3.org/TR/wai-aria-1.1/#aria-modal

次にモーダルの内容を示すために aria-labelledby 属性、aria-describedby 属性を付与します。aria-labelledby 属性にはモーダルを簡潔に表現する要素の id、aria-describedby 属性にはモーダルをより詳細に説明する要素の id を指定します。指定する id はモーダルの内容によって変わってくるので、Modal コンポーネントの Props でこれらの属性を受け付け、そのままモーダル要素へ渡すようにしましょう。

aria-labelledby (property):
https://www.w3.org/TR/wai-aria-1.1/#aria-labelledby

aria-describedby (property):
https://www.w3.org/TR/wai-aria-1.1/#aria-describedby

type Props = {
  isOpen: boolean;
+ "aria-labelledby": string;
+ "aria-describedby": string;
};

- const Modal: React.FC<Props> = ({ isOpen, children }) => {
+ const Modal: React.FC<Props> = ({
+   isOpen,
+   "aria-labelledby": ariaLabelledby,
+   "aria-describedby": ariaDescribedby,
+   children,
+ }) => {
  if (!isOpen) {
    return null;
  }

  return createPortal(
    <div className={styles.overlay}>
-     <div role="dialog" aria-modal="true" className={styles.content}>
+     <div
+       role="dialog"
+       aria-modal="true"
+       aria-labelledby={ariaLabelledby}
+       aria-describedby={ariaDescribedby}
+       className={styles.content}
+     >
        {children}
      </div>
    </div>,
    document.body,
  );
};

使うときは、次のようにそれぞれの要素の id を指定します。

<Modal
  isOpen={isOpen}
  aria-labelledby="modal_header"
  aria-describedby="modal_body"
>
  <h1 id="modal_header">Modal Example</h1>
  <p id="modal_body">This is modal example.</p>
</Modal>

最後に、モーダルが開いている間はモーダル以外の要素に aria-hidden 属性が付与されるようにします。aria-hidden 属性を付与すると、その要素をスクリーンリーダーなどの支援技術から隠蔽することができます。

実は、aria-hidden は先述の aria-modal 属性で置き換えることができます。つまり、モーダル要素に modal-aria 属性を付与することと、モーダル要素以外に aria-hidden 属性を付与することは同じ役割を持ちます。しかし、aria-modal に対応していないスクリーンリーダーのためにどちらも指定しておくのが無難です。

The aria-modal property introduced by ARIA 1.1 replaces aria-hidden for informing assistive technologies that content outside a dialog is inert.

https://www.w3.org/TR/wai-aria-practices-1.1/#dialog_roles_states_props

aria-hidden 属性を付与する処理をスクラッチで実装するのもいいですが、今回は aria-hidden というパッケージに頼りましょう。aria-hidden を使うと、指定した要素を除く要素(正確にはその兄弟要素と、その祖先要素を除く body 直下の要素)に aria-hidden 属性を付与する処理とそれを undo する処理を簡単に書くことができます。

https://www.npmjs.com/package/aria-hidden

import { hideOthers } from "aria-hidden";

const undo = hideOthers(element);
undo();

aria-hidden を使って次のような useAriaHidden を実装し、Modal コンポーネント内で呼び出します。

import { RefObject, useEffect } from "react";
import { hideOthers } from "aria-hidden";

function useAriaHidden(ref: RefObject<HTMLElement>, isOpen: boolean): void {
  useEffect(() => {
    if (!isOpen || ref.current === null) {
      return;
    }

    return hideOthers(ref.current);
  }, [ref, isOpen]);
}
const Modal: React.FC<Props> = ({
  // ...
}) => {
+ const ref = useRef<HTMLElement>(null);
+ useAriaHidden(ref, isOpen);
+
  if (!isOpen) {
    return null;
  }

  return createPortal(
    <div className={styles.overlay}>
      <div
+       ref={ref}
        role="dialog"
        aria-modal="true"
        aria-labelledby={ariaLabelledby}
        aria-describedby={ariaDescribedby}
        className={styles.content}
      >
        {children}
      </div>
    </div>,
    document.body,
  );
};

これでモーダルが開いている間は aria-hidden 属性が付与され、閉じると元に戻るようになりました。

フォーカストラップ

この節では以下を実装します。

  • モーダルを開くと、モーダル内の最初の focusable な要素に自動でフォーカスされる
  • モーダルが開いている間、モーダル内でフォーカスがトラップされる
  • Esc キーを押下すると、モーダルが閉じる
  • モーダルの外側をクリックすると、モーダルが閉じる
  • モーダルを閉じると、モーダルが開く前にフォーカスされていた要素にフォーカスが戻る

実は上記をすべて解決してくれる focus-trap というパッケージがあります。focus-trap を使うと、モーダル内からフォーカスが逃げないようにする処理(いわゆるフォーカストラップ)と、それに関する処理を簡単に実装することができます。

https://www.npmjs.com/package/focus-trap

// 使用例

import { createFocusTrap } from "focus-trap";

const trap = createFocusTrap(element, {
  clickOutsideDeactivates: true, // 要素の外側をクリックしたときに無効化する
  escapeDeactivates: true, // Esc キーを押したときに無効化する
  returnFocusOnDeactivate: true, // 無効化したときにフォーカスを元の要素に戻す
  onDeactivate: onClose, // 無効化したときに実行するコールバック関数
  // initialFocus: 最初にフォーカスする要素を指定する; デフォルトは要素内の最初の focusable な要素
});

trap.activate(); // 有効化
trap.deactivate(); // 無効化

focus-trap を使い、次のような useFocusTrap を実装します。

import { RefObject, useEffect } from "react";
import { createFocusTrap } from "focus-trap";

type UseFocusTrapOptions = {
  ref: RefObject<HTMLElement>;
  isOpen: boolean;
  onClose: () => void;
};

function useFocusTrap({ ref, isOpen, onClose }: UseFocusTrapOptions): void {
  useEffect(() => {
    if (!isOpen || ref.current === null) {
      return;
    }

    const trap = createFocusTrap(ref.current, {
      clickOutsideDeactivates: true,
      escapeDeactivates: true,
      returnFocusOnDeactivate: true,
      onDeactivate: onClose,
    });
    trap.activate();

    return () => {
      trap.deactivate();
    };
  }, [ref, isOpen, onClose]);
}

1 つ注意点があり、focus-trap は指定した要素内に focusable な要素がない場合に例外を投げます。ARIA 1.1 でもモーダルは 1 つ以上の focusable な要素を持つべきで、モーダルが開かれたときにはモーダル内の要素にフォーカスするべきだと記載されているので、従うようにしましょう。

Authors SHOULD ensure that all dialogs (both modal and non-modal) have at least one focusable descendant element. Authors SHOULD focus an element in the modal dialog when it is displayed, and authors SHOULD manage focus of modal dialogs.

https://www.w3.org/TR/wai-aria-1.1/#dialog

先ほど実装した useFocusTrapModal コンポーネント内で呼び出します。

type Props = {
  // ...
+ onClose: () => void;
};

const Modal: React.FC<Props> = ({
  // ...
+ onClose,
}) => {
  const ref = useRef<HTMLElement>(null);
  useAriaHidden(ref, isOpen);
+ useFocusTrap({ ref, isOpen, onClose });

  if (!isOpen) {
    return null;
  }

  // ...
};

これでフォーカストラップまわりのアクセシビリティ要件を満たすことができました。

スクロールの無効化

この節では以下を実装します。これが最後の要件です。

  • モーダルが開いている間、モーダル以外の要素のスクロールが無効化される

こちらもパッケージを使いましょう。body-scroll-lock というパッケージを使って実装できます。body-scroll-lock は指定した要素のスクロールは有効なまま、body のスクロールを無効してくれます。

https://www.npmjs.com/package/body-scroll-lock

body-scroll-lock を使い、次のような useDisableScroll を実装します。

import { RefObject, useEffect } from "react";
import { clearAllBodyScrollLocks, disableBodyScroll } from "body-scroll-lock";

function useDisableScroll(ref: RefObject<HTMLElement>, isOpen: boolean): void {
  useEffect(() => {
    if (!isOpen || ref.current === null) {
      return;
    }

    disableBodyScroll(ref.current);

    return clearAllBodyScrollLocks;
  }, [ref, isOpen]);
}

useDisableScrollModal コンポーネント内で呼び出します。

const Modal: React.FC<Props> = ({
  // ...
}) => {
  const ref = useRef<HTMLElement>(null);
  useAriaHidden(ref, isOpen);
  useFocusTrap({ ref, isOpen, onClose });
+ useDisableScroll(ref, isOpen);

  if (!isOpen) {
    return null;
  }

  // ...
};

以上です。これで、モーダルを開いているときに背後の要素がスクロールするのを防ぐことができました。

完成

これで冒頭のアクセシビリティ要件をすべて満たすモーダルコンポーネントが作成できました。

type Props = {
  isOpen: boolean;
  onClose: () => void;
  "aria-labelledby": string;
  "aria-describedby": string;
};

const Modal: React.FC<Props> = ({
  isOpen,
  onClose,
  "aria-labelledby": ariaLabelledby,
  "aria-describedby": ariaDescribedby,
  children,
}) => {
  const ref = useRef<HTMLElement>(null);
  useAriaHidden(ref, isOpen);
  useFocusTrap({ ref, isOpen, onClose });
  useDisableScroll(ref, isOpen);

  if (!isOpen) {
    return null;
  }

  return createPortal(
    <div className={classes.overlay}>
      <div
        ref={ref}
        role="dialog"
        aria-modal="true"
        aria-labelledby={ariaLabelledby}
        aria-describedby={ariaDescribedby}
        className={classes.content}
      >
        {children}
      </div>
    </div>,
    document.body,
  );
};
使用例
const App: React.VFC = () => {
  const [isOpen, setIsOpen] = useState(false);

  const open = useCallback(() => {
    setIsOpen(true);
  }, []);
  const close = useCallback(() => {
    setIsOpen(false);
  }, []);

  return (
    <>
      <button onClick={open}>Open</button>

      <Modal
        isOpen={isOpen}
        onClose={close}
        aria-labelledby="modal_header"
        aria-describedby="modal_body"
      >
        <h1 id="modal_header">Modal Example</h1>
        <p id="modal_body">This is modal example.</p>
        <button onClick={close}>Close</button>
      </Modal>
    </>
  );
};

おまけ

フォーカストラップの具体的な実装を知りたい方向けに、focus-trap を使わない場合の実装例を雑に記載しておきます。

focus-trap を使わない場合の実装例

代わりに tabbable というパッケージを使います。tabbable を使うと、指定した要素内の tabbable な要素を取り出すことができます。

https://www.npmjs.com/package/tabbable

import { tabbable } from "tabbable";

function focusNextElement(element: Element, reverse: boolean): void {
  const tabbables = tabbable(element);
  const currentIndex = tabbables.findIndex(
    (el) => el === document.activeElement,
  );

  if (currentIndex === -1) {
    // モーダル内にフォーカスされている要素がない場合、最初の要素にフォーカスする
    tabbables[0]?.focus();
    return;
  }

  const nextIndex = currentIndex + (reverse ? -1 : 1);

  if (nextIndex === -1) {
    // 前の要素が存在しない場合、最後の要素にフォーカスする
    tabbables[tabbables.length - 1]?.focus();
    return;
  }

  if (nextIndex === tabbables.length) {
    // 次の要素が存在しない場合、最初の要素にフォーカスする
    tabbables[0]?.focus();
    return;
  }

  // それ以外の場合、次の要素にフォーカスする
  const nextElement = tabbables[nextIndex];
  nextElement?.focus();
}
import { RefObject, useEffect } from "react";
import { focusNextElement } from "./focusNextElement";

type UseFocusTrapOptions = {
  ref: RefObject<HTMLElement>;
  isOpen: boolean;
  onClose: () => void;
};

// フォーカストラップ
function useFocusTrap({ ref, isOpen, onClose }: UseFocusTrapOptions): void {
  useEffect(() => {
    if (!isOpen) {
      return;
    }

    const handleKeydown = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        event.preventDefault();
        onClose();
        return;
      }

      if (event.key === "Tab") {
        event.preventDefault();
        if (ref.current !== null) {
          // Shift キーが押下されている場合は逆方向にフォーカスを移動する
          focusNextElement(ref.current, event.shiftKey);
        }
        return;
      }
    };

    document.body.addEventListener("keydown", handleKeydown);

    return () => {
      document.body.removeEventListener("keydown", handleKeydown);
    };
  }, [ref, isOpen, onClose]);
}
import { RefObject, useEffect } from "react";
import { focusNextElement } from "./focusNextElement";

// モーダル内の最初の要素にフォーカスする
function useFocusFirstElement(
  isOpen: boolean,
  ref: RefObject<HTMLElement>,
): void {
  useEffect(() => {
    if (!isOpen || ref.current === null) {
      return;
    }

    focusNextElement(ref.current, false);
  }, [isOpen, ref]);
}
import { useEffect, useRef } from "react";

// モーダルを閉じたときにフォーカスを戻す
function useReturnFocus(isOpen: boolean): void {
  const returnFocusElement = useRef<HTMLElement>(null);

  useEffect(() => {
    if (!isOpen) {
      return;
    }

    const element = document.activeElement;

    if (element !== null && element instanceof HTMLElement) {
      returnFocusElement.current = element;
    }

    return () => {
      returnFocusElement.current?.focus();
    };
  }, [isOpen]);
}
GitHubで編集を提案

Discussion

petamorikenpetamoriken

補足情報です。

モーダルが開いている間、それ以外の部分についてはフォーカストラップやスクロール無効化以外にも user-select: none; を使って選択範囲から除外する処理を追加したほうがいいかもしれません。

また将来的には inert 属性を使うことで楽に実装できるようになる話があったりしますね。

https://scrapbox.io/petamoriken/ちゃんとしたモーダルウィンドウを作る

ki504178ki504178

@dqn

こちらの記事大変参考になりました。ありがとうございます!!
ただ、記事を参考にしながらモーダルを実装していたのですが、
useXXがすべて効かない状態でした。(同じソースコードを使っていたわけでないです)
以下のようにコールバックRef形式で実装し直したところ、
想定通りに動作するようになりましたため共有いたします。

環境

  • Next.js: 12.1.6
  • React: 18.1.0
-  const ref = useRef<HTMLElement>(null);
-  useAriaHidden(ref, isOpen);
-  useFocusTrap({ ref, isOpen, onClose });
-  useDisableScroll(ref, isOpen);
-  const ref = useRef<HTMLElement>(null);
+  const cbAriaHidden = useAriaHidden(isOpen);
+  const cbForcusTrap = useFocusTrap({ isOpen, onClose });
+  const cbDisableScroll = useDisableScroll(isOpen);

  if (!isOpen) {
    return null;
  }

  return createPortal(
    <div className={classes.overlay}>
      <div
-        ref={ref}
+        ref={(e) => {
+          cbAriaHidden(e)
+          cbForcusTrap(e)
+          cbDisableScroll(e)
+        }}

※下記以外のuseXXソースコードについては、基本的に同じ修正方針を適用する前提のため省略しています

useFocusTrap.ts
- import { RefObject, useEffect } from "react";
+ import { useEffect, useState } from "react";
import { createFocusTrap } from "focus-trap";

type UseFocusTrapOptions = {
-  ref: RefObject<HTMLElement>;
  isOpen: boolean;
  onClose: () => void;
};

- function useFocusTrap({ ref, isOpen, onClose }: UseFocusTrapOptions): void {
+ function useFocusTrap({ isOpen, onClose }: UseFocusTrapOptions): void {
+ const [element, setElement] = useState<HTMLElement | null>(null);
  useEffect(() => {
-   if (!isOpen || ref.current === null) {
+   if (!isOpen || element === null) {
      return;
    }

    const trap = createFocusTrap(ref.current, {
      clickOutsideDeactivates: true,
      escapeDeactivates: true,
      returnFocusOnDeactivate: true,
      onDeactivate: onClose,
    });
    trap.activate();

    return () => {
      trap.deactivate();
    };
- }, [ref, isOpen, onClose]);
+ }, [isOpen, onClose]);
+
+  return (e: HTMLElement | null) => {
+    if (!e) return;
+    setElement(e);
+  }
}