実装しながら理解するモーダルのアクセシビリティ with React
はじめに
この記事では以下のアクセシビリティ要件を満たすモーダルを実装します。
- モーダル要素に
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
.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):
aria-modal (property):
次にモーダルの内容を示すために aria-labelledby
属性、aria-describedby
属性を付与します。aria-labelledby
属性にはモーダルを簡潔に表現する要素の id、aria-describedby
属性にはモーダルをより詳細に説明する要素の id を指定します。指定する id はモーダルの内容によって変わってくるので、Modal
コンポーネントの Props
でこれらの属性を受け付け、そのままモーダル要素へ渡すようにしましょう。
aria-labelledby (property):
aria-describedby (property):
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.
aria-hidden
属性を付与する処理をスクラッチで実装するのもいいですが、今回は aria-hidden というパッケージに頼りましょう。aria-hidden を使うと、指定した要素を除く要素(正確にはその兄弟要素と、その祖先要素を除く body 直下の要素)に aria-hidden
属性を付与する処理とそれを undo する処理を簡単に書くことができます。
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 を使うと、モーダル内からフォーカスが逃げないようにする処理(いわゆるフォーカストラップ)と、それに関する処理を簡単に実装することができます。
// 使用例
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.
先ほど実装した useFocusTrap
を Modal
コンポーネント内で呼び出します。
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 のスクロールを無効してくれます。
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]);
}
useDisableScroll
を Modal
コンポーネント内で呼び出します。
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 な要素を取り出すことができます。
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]);
}
Discussion
補足情報です。
モーダルが開いている間、それ以外の部分についてはフォーカストラップやスクロール無効化以外にも
user-select: none;
を使って選択範囲から除外する処理を追加したほうがいいかもしれません。また将来的には
inert
属性を使うことで楽に実装できるようになる話があったりしますね。情報ありがとうございます 🙏
@dqn
こちらの記事大変参考になりました。ありがとうございます!!
ただ、記事を参考にしながらモーダルを実装していたのですが、
useXX
がすべて効かない状態でした。(同じソースコードを使っていたわけでないです)以下のようにコールバックRef形式で実装し直したところ、
想定通りに動作するようになりましたため共有いたします。
環境
※下記以外の
useXX
ソースコードについては、基本的に同じ修正方針を適用する前提のため省略しています